clavarium 0.2.370-next.1 → 0.2.372-next.1
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/npm-shrinkwrap.json +6 -6
- package/package.json +2 -2
- package/server/api.mjs +12 -0
- package/server/demandes.mjs +29 -0
- package/server/mcpx.mjs +154 -0
- package/server/settingswords.mjs +16 -0
- package/web/app.js +98 -4
- package/web/index.html +18 -0
- package/web/kit.js +18 -6
- package/web/style.css +11 -0
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.372-next.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "clavarium",
|
|
9
|
-
"version": "0.2.
|
|
9
|
+
"version": "0.2.372-next.1",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"clavarium-binder": "0.1.62-next.1",
|
|
15
15
|
"clavarium-docket": "0.1.2-next.2",
|
|
16
16
|
"clavarium-parley": "0.17.86-next.1",
|
|
17
|
-
"clavarium-tether": "0.1.0-next.
|
|
17
|
+
"clavarium-tether": "0.1.0-next.57",
|
|
18
18
|
"mammoth": "1.13.0",
|
|
19
19
|
"mermaid": "11.17.2",
|
|
20
20
|
"node-pty": "^1.1.0",
|
|
@@ -800,9 +800,9 @@
|
|
|
800
800
|
}
|
|
801
801
|
},
|
|
802
802
|
"node_modules/clavarium-tether": {
|
|
803
|
-
"version": "0.1.0-next.
|
|
804
|
-
"resolved": "https://registry.npmjs.org/clavarium-tether/-/clavarium-tether-0.1.0-next.
|
|
805
|
-
"integrity": "sha512-
|
|
803
|
+
"version": "0.1.0-next.57",
|
|
804
|
+
"resolved": "https://registry.npmjs.org/clavarium-tether/-/clavarium-tether-0.1.0-next.57.tgz",
|
|
805
|
+
"integrity": "sha512-klagmTU3NdzMO6fA+nMvLg9UTW9XOWN1QYa1JF4ywykeliJsAMNOIWzAvlsVFEMPSg0t4IkcZq/Ui7dyTdDHDg==",
|
|
806
806
|
"license": "MIT",
|
|
807
807
|
"bin": {
|
|
808
808
|
"tether": "bin/cli.mjs",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.372-next.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A conversation is a VM with a terminal in it. Everything else is a folder.",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"clavarium-binder": "0.1.62-next.1",
|
|
48
48
|
"clavarium-docket": "0.1.2-next.2",
|
|
49
49
|
"clavarium-parley": "0.17.86-next.1",
|
|
50
|
-
"clavarium-tether": "0.1.0-next.
|
|
50
|
+
"clavarium-tether": "0.1.0-next.57",
|
|
51
51
|
"mammoth": "1.13.0",
|
|
52
52
|
"mermaid": "11.17.2",
|
|
53
53
|
"node-pty": "^1.1.0",
|
package/server/api.mjs
CHANGED
|
@@ -167,6 +167,7 @@ import { WORK_TAB } from './resources.mjs';
|
|
|
167
167
|
import { transcribe, AUDIO_MAX } from './voice.mjs';
|
|
168
168
|
import { FILE_MAX } from './setup.mjs';
|
|
169
169
|
import { demandeView } from './demandes.mjs';
|
|
170
|
+
import { mcpAdd, mcpImport, mcpRefresh, mcpRights, mcpSeen, forgetKey } from './mcpx.mjs';
|
|
170
171
|
|
|
171
172
|
const BODY_MAX = 16 * 1024;
|
|
172
173
|
/* A setup file travels inside the JSON of a request: room for it, escaped. */
|
|
@@ -614,7 +615,18 @@ export async function handleApi(request, response, conversations, headers, resou
|
|
|
614
615
|
/* Assets > Access: the owner sets a conversation's level and rules on a
|
|
615
616
|
target, decides a call that waited, or removes a target. Each is an op the
|
|
616
617
|
keeper runs as the owner (person), never as an agent. */
|
|
618
|
+
/* External MCP servers (mcpx.mjs): added with their key (kept host-only in the vault, Tether given its name),
|
|
619
|
+
imported from a pasted mcpServers block, listed again, a conversation's rights on their tools, new tools seen. */
|
|
620
|
+
const mcpOp = /^\/api\/tether\/mcp-(add|import|refresh|rights|seen)$/.exec(pathname);
|
|
621
|
+
if (mcpOp && tethers) {
|
|
622
|
+
const ctx = { tethers, secrets };
|
|
623
|
+
const done = await { add: mcpAdd, import: mcpImport, refresh: mcpRefresh, rights: mcpRights, seen: mcpSeen }[mcpOp[1]](ctx, body ?? {});
|
|
624
|
+
return send(mcpOp[1] === 'add' || mcpOp[1] === 'import' ? 201 : 200, done), true;
|
|
625
|
+
}
|
|
626
|
+
/* A server taken away: the key kept for it goes too. */
|
|
627
|
+
const mcpGone = pathname === '/api/tether/remove-target' && tethers ? tethers.store().target(String(body?.target ?? '')) : null;
|
|
617
628
|
const tetherOp = /^\/api\/tether\/(level|allow|unallow|decide|remove-target|stop|resume|job-done|job-file)$/.exec(pathname);
|
|
629
|
+
if (tetherOp && tethers && mcpGone?.kind === 'mcp') { const r = await tethers.act('remove-target', body, ''); if (!r.ok) throw new Refusal(400, r.text); forgetKey(secrets, mcpGone); return send(200, r.data ?? { ok: true }), true; }
|
|
618
630
|
if (tetherOp && tethers) { const r = await tethers.act(tetherOp[1], body, ''); if (!r.ok) throw new Refusal(400, r.text); return send(200, r.data ?? { ok: true }), true; }
|
|
619
631
|
/* A mailbox's signature (Markdown), added under what the agent sends from it. */
|
|
620
632
|
if (pathname === '/api/tether/signature' && tethers) {
|
package/server/demandes.mjs
CHANGED
|
@@ -32,6 +32,34 @@ const whom = (list) => (list.length ? `${list[0]}${list.length > 1 ? ` +${list.l
|
|
|
32
32
|
const plain = (body) => String(body ?? '').replace(/\*\*(\S[^*\n]*?)\*\*/g, '$1').replace(/__(\S[^_\n]*?)__/g, '$1').replace(/(^|[^*\w])\*(\S[^*\n]*?)\*(?!\w)/g, '$1$2')
|
|
33
33
|
.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, '$1 ($2)').replace(/^#{1,6}\s+/gm, '');
|
|
34
34
|
|
|
35
|
+
/* A CALL OF AN EXTERNAL MCP SERVER'S TOOL (Tether's card { kind: 'mcp', action: 'call', server, tool, title?, class, open?,
|
|
36
|
+
* fresh?, args }, 07-11): which server, which tool, its class, its arguments in short, the AI's why. Parley has no card
|
|
37
|
+
* of this kind nor its words yet: drawn as its generic card (kind 'other'), each word with its English (`t`), which an
|
|
38
|
+
* older or unknowing screen says as it is. A tool that deletes or overwrites: irreversible (amber, never in Accept all). */
|
|
39
|
+
export const MCP_CLASS_SAID = { read: 'Reads only', write: 'Writes', destructive: 'Deletes or overwrites' };
|
|
40
|
+
const MCP_ARGS_MAX = 6; const MCP_ARG_MAX = 160;
|
|
41
|
+
const said = (k, t, v) => ({ k, ...(v ? { v } : {}), t });
|
|
42
|
+
function mcpCard(c, v, sub) {
|
|
43
|
+
const server = String(c.server ?? '') || 'an MCP server';
|
|
44
|
+
const tool = String(c.title || c.tool || 'a tool');
|
|
45
|
+
const cls = Object.hasOwn(MCP_CLASS_SAID, c.class) ? c.class : 'destructive';
|
|
46
|
+
v.title = said('req-mcp-call', `Use ${tool} on ${server}`, { tool, server });
|
|
47
|
+
sub.push(server, said(`req-mcp-class-${cls}`, MCP_CLASS_SAID[cls]));
|
|
48
|
+
v.facts.push([said('req-fact-server', 'Server'), server]);
|
|
49
|
+
v.facts.push([said('req-fact-tool', 'Tool'), c.title && c.tool && c.title !== c.tool ? `${c.title} (${c.tool})` : tool]);
|
|
50
|
+
v.facts.push([said('req-fact-class', 'Kind'), said(`req-mcp-class-${cls}`, MCP_CLASS_SAID[cls])]);
|
|
51
|
+
if (c.fresh === true) v.risk.push(said('req-risk-mcp-fresh', 'New or changed on its server'));
|
|
52
|
+
if (cls === 'destructive') v.risk.push(said('req-risk-mcp-destructive', 'May delete or overwrite'));
|
|
53
|
+
if (c.open === true && cls !== 'read') v.risk.push(said('req-risk-mcp-open', 'Reaches outside'));
|
|
54
|
+
/* Its arguments, in short: a name and its value on a line each, the first few. */
|
|
55
|
+
const args = Array.isArray(c.args) ? c.args.filter((a) => a && typeof a.name === 'string') : [];
|
|
56
|
+
if (args.length) {
|
|
57
|
+
const line = (a) => { const t = String(a.value ?? '').replace(/\s+/g, ' ').trim(); return `${a.name}: ${t.length > MCP_ARG_MAX ? `${t.slice(0, MCP_ARG_MAX - 1)}…` : t}`; };
|
|
58
|
+
v.code = [...args.slice(0, MCP_ARGS_MAX).map(line), ...(args.length > MCP_ARGS_MAX ? [`… +${args.length - MCP_ARGS_MAX}`] : [])].join('\n');
|
|
59
|
+
}
|
|
60
|
+
v.irreversible = cls === 'destructive';
|
|
61
|
+
}
|
|
62
|
+
|
|
35
63
|
export function demandeView(q) {
|
|
36
64
|
const c = q?.card ?? {}; const about = c.about ?? {};
|
|
37
65
|
const kind = KINDS.has(c.kind) ? c.kind : 'other';
|
|
@@ -152,6 +180,7 @@ export function demandeView(q) {
|
|
|
152
180
|
v.yes = c.action === 'delete' ? 'delete' : c.action === 'move' ? 'move' : 'write';
|
|
153
181
|
if (c.action === 'delete') { v.irreversible = true; v.risk.push(w('req-risk-final')); }
|
|
154
182
|
} else if (c.kind === 'browser' && c.action === 'open') { v.title = w('req-browser-open', { url: String(c.url) }); v.yes = 'open'; if (where) sub.push(where); }
|
|
183
|
+
else if (c.kind === 'mcp') mcpCard(c, v, sub);
|
|
155
184
|
else { v.title = `${q?.verb ?? 'action'}${q?.detail ? ` ${q.detail}` : ''}`; if (where) sub.push(where); }
|
|
156
185
|
const id = String(q?.id ?? '');
|
|
157
186
|
return {
|
package/server/mcpx.mjs
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/* EXTERNAL MCP SERVERS, THROUGH TETHER (07-11). The owner adds a server reached over Streamable HTTP
|
|
2
|
+
* (Assets › Access): its name, its address, its key. Tether (clavarium-tether 0.1.0-next.56, a target of
|
|
3
|
+
* kind `mcp`) lists its tools, sorts them by their annotations (read / write / destructive), offers them to
|
|
4
|
+
* each conversation by its rights, and asks a person where they say so (a card, demandes.mjs).
|
|
5
|
+
*
|
|
6
|
+
* THE KEY STAYS HERE. It is kept in this server's vault as a host-only secret (secrets.mjs: never put in a
|
|
7
|
+
* machine), named MCP_<ID>_KEY, and Tether is handed only that NAME: the keeper reveals it for the
|
|
8
|
+
* connector alone. It is never logged, returned or said back -- not even in an error (every answer is
|
|
9
|
+
* scrubbed of it before it leaves). A pasted `mcpServers` file names its keys the same way ("${NAME}"),
|
|
10
|
+
* each a secret already in the vault; a value written in it, or a program to start (stdio), is refused.
|
|
11
|
+
*
|
|
12
|
+
* Each function answers the route's body, or throws a Refusal (the page says its message). */
|
|
13
|
+
import { Refusal } from './conversations.mjs';
|
|
14
|
+
|
|
15
|
+
const AGENT = 'conversation:';
|
|
16
|
+
export const KEY_PREFIX = 'MCP_';
|
|
17
|
+
export const KEY_SUFFIX = '_KEY';
|
|
18
|
+
/* MCP_ + the id + _KEY within a secret's 40 characters. */
|
|
19
|
+
const ID_MAX = 40 - KEY_PREFIX.length - KEY_SUFFIX.length;
|
|
20
|
+
const REF = /^[A-Z][A-Z0-9_]{0,39}$/;
|
|
21
|
+
const HEADER_REF = /^(Bearer\s+)?\$\{([A-Za-z0-9_][A-Za-z0-9_.:-]{0,99})\}$/;
|
|
22
|
+
const CLASSES = ['read', 'write', 'destructive'];
|
|
23
|
+
const GOES = ['yes', 'ask', 'no'];
|
|
24
|
+
|
|
25
|
+
/* A server's id from its name: small letters, digits and dashes, as Tether keeps it. */
|
|
26
|
+
export function serverId(name) {
|
|
27
|
+
return String(name ?? '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, ID_MAX).replace(/-+$/, '');
|
|
28
|
+
}
|
|
29
|
+
/* The vault's name for a server's key. */
|
|
30
|
+
export const keyName = (id) => `${KEY_PREFIX}${String(id).toUpperCase().replace(/[^A-Z0-9]/g, '_')}${KEY_SUFFIX}`;
|
|
31
|
+
|
|
32
|
+
/* Everything said back, scrubbed of the key: a server that echoes it, an error that quotes it. */
|
|
33
|
+
function scrub(data, key) {
|
|
34
|
+
if (!key) return data;
|
|
35
|
+
try { return JSON.parse(JSON.stringify(data ?? null).split(key).join('[key]').split(JSON.stringify(key).slice(1, -1)).join('[key]')); } catch { return null; }
|
|
36
|
+
}
|
|
37
|
+
const said = (text, key) => (key ? String(text ?? '').split(key).join('[key]') : String(text ?? ''));
|
|
38
|
+
const known = (secrets, name) => { try { return (secrets?.list({ held: true }) ?? []).some((s) => s.name === name); } catch { return false; } };
|
|
39
|
+
/* A target as the page reads it: never a secret's value (Tether gives only their slots' names). */
|
|
40
|
+
const shown = (t) => { if (!t || typeof t !== 'object') return t; const { secrets: _s, ...rest } = t; return { ...rest, secrets: Array.isArray(_s) ? _s : Object.keys(_s ?? {}) }; };
|
|
41
|
+
|
|
42
|
+
/* ADD (or set again) one server: { name, url, key?, header?, allowHttp? } -> the target, its tools listed (or `error`). */
|
|
43
|
+
export async function mcpAdd({ tethers, secrets }, body = {}) {
|
|
44
|
+
const name = String(body.name ?? '').trim();
|
|
45
|
+
const url = String(body.url ?? '').trim();
|
|
46
|
+
const key = typeof body.key === 'string' ? body.key.trim() : '';
|
|
47
|
+
if (!name) throw new Refusal(400, 'Give the server a name.');
|
|
48
|
+
if (name.length > 120) throw new Refusal(400, 'A name is 120 characters at most.');
|
|
49
|
+
const id = serverId(name);
|
|
50
|
+
if (!id) throw new Refusal(400, 'Give the server a name with letters or digits.');
|
|
51
|
+
if (!url) throw new Refusal(400, 'Give the server\'s address (https://...).');
|
|
52
|
+
if (key && /[\r\n\0]/.test(key)) throw new Refusal(400, 'A key is one line.');
|
|
53
|
+
const before = tethers.store().target(id);
|
|
54
|
+
if (before && before.kind !== 'mcp') throw new Refusal(409, 'That name is taken by another access.');
|
|
55
|
+
const ref = keyName(id);
|
|
56
|
+
const had = known(secrets, ref);
|
|
57
|
+
/* Its key, in the vault, host-only: Tether gets its name. None given: the one it had, or none. */
|
|
58
|
+
let secret = '';
|
|
59
|
+
if (key) { secrets.set({ name: ref, value: key, hostOnly: true }); secret = ref; }
|
|
60
|
+
else if (before?.secrets?.token) secret = String(before.secrets.token);
|
|
61
|
+
const r = await tethers.act('mcp-add', { id, name, url, ...(secret ? { secret } : {}), ...(body.header ? { header: String(body.header) } : {}), ...(body.allowHttp === true ? { allowHttp: true } : {}) }, '');
|
|
62
|
+
if (!r.ok) {
|
|
63
|
+
/* Nothing was made: a key kept for it alone goes too. */
|
|
64
|
+
if (key && !had && !before) { try { secrets.remove(ref); } catch { /* not there */ } }
|
|
65
|
+
throw new Refusal(400, said(r.text, key));
|
|
66
|
+
}
|
|
67
|
+
return scrub(shown(r.data), key);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* IMPORT a pasted { "mcpServers": { name: { url, headers: { Authorization: "Bearer ${NAME}" } } } }: each key a
|
|
71
|
+
reference to a secret of the vault (none written in it), no program to start. -> the targets made. */
|
|
72
|
+
export async function mcpImport({ tethers, secrets }, body = {}) {
|
|
73
|
+
let config = body.config;
|
|
74
|
+
if (typeof config === 'string') {
|
|
75
|
+
try { config = JSON.parse(config); } catch { throw new Refusal(400, 'That is not JSON: paste the whole { "mcpServers": ... } block.'); }
|
|
76
|
+
}
|
|
77
|
+
const all = config && typeof config === 'object' ? (config.mcpServers ?? config.servers) : null;
|
|
78
|
+
if (!all || typeof all !== 'object' || Array.isArray(all) || !Object.keys(all).length) throw new Refusal(400, 'Paste a block like { "mcpServers": { "name": { "url": "https://...", "headers": { "Authorization": "Bearer ${SECRET}" } } } }.');
|
|
79
|
+
const servers = {};
|
|
80
|
+
for (const [name, s] of Object.entries(all)) {
|
|
81
|
+
const label = String(name).slice(0, 60);
|
|
82
|
+
if (!s || typeof s !== 'object' || Array.isArray(s)) throw new Refusal(400, `${label}: not a server.`);
|
|
83
|
+
if (s.command !== undefined || s.args !== undefined || s.type === 'stdio') throw new Refusal(400, `${label}: a program to start (stdio) is not run here. Only a server reached by its address.`);
|
|
84
|
+
const heads = s.headers && typeof s.headers === 'object' && !Array.isArray(s.headers) ? Object.entries(s.headers) : [];
|
|
85
|
+
if (heads.length > 1) throw new Refusal(400, `${label}: one header for the key at most.`);
|
|
86
|
+
for (const [h, v] of heads) {
|
|
87
|
+
const m = HEADER_REF.exec(String(v ?? '').trim());
|
|
88
|
+
/* Never said back: the header holds a value, whatever it is. */
|
|
89
|
+
if (!m) throw new Refusal(400, `${label}: the header ${String(h).slice(0, 60)} holds a value. Write a reference instead: "\${SECRET_NAME}".`);
|
|
90
|
+
if (!REF.test(m[2]) || !known(secrets, m[2])) throw new Refusal(400, `${label}: no secret named ${m[2].slice(0, 60)} here. Add it in Secrets first.`);
|
|
91
|
+
}
|
|
92
|
+
if (s.env && typeof s.env === 'object' && Object.keys(s.env).length) throw new Refusal(400, `${label}: "env" is for a program to start. Only a server reached by its address.`);
|
|
93
|
+
servers[name] = { url: s.url ?? s.serverUrl, ...(heads.length ? { headers: Object.fromEntries(heads) } : {}), ...(s.allowHttp === true ? { allowHttp: true } : {}) };
|
|
94
|
+
}
|
|
95
|
+
const r = await tethers.act('mcp-import', { config: { mcpServers: servers } }, '');
|
|
96
|
+
if (!r.ok) throw new Refusal(400, r.text);
|
|
97
|
+
return { servers: (Array.isArray(r.data) ? r.data : []).map(shown) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* LIST AGAIN one server's tools, now. */
|
|
101
|
+
export async function mcpRefresh({ tethers }, body = {}) {
|
|
102
|
+
const target = String(body.target ?? '');
|
|
103
|
+
const t = tethers.store().target(target);
|
|
104
|
+
if (!t || t.kind !== 'mcp') throw new Refusal(404, 'No such MCP server.');
|
|
105
|
+
const r = await tethers.act('mcp-refresh', { target }, '');
|
|
106
|
+
const now = tethers.store().target(target);
|
|
107
|
+
/* Not reached: said, its last list kept. */
|
|
108
|
+
return { catalog: now?.catalog ?? null, ...(r.ok ? {} : { error: r.text }) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* ONE CONVERSATION'S RIGHTS on a server, beyond its level: { target, agent, classes?, tools?, askOpen? } (null takes
|
|
112
|
+
a setting back). Its level (Off / Read / All with approval / All / Custom) is /api/tether/level. */
|
|
113
|
+
export async function mcpRights({ tethers }, body = {}) {
|
|
114
|
+
const target = String(body.target ?? '');
|
|
115
|
+
const agent = String(body.agent ?? '');
|
|
116
|
+
const t = tethers.store().target(target);
|
|
117
|
+
if (!t || t.kind !== 'mcp') throw new Refusal(404, 'No such MCP server.');
|
|
118
|
+
if (!agent.startsWith(AGENT) || agent.length <= AGENT.length) throw new Refusal(400, 'Rights are given to a conversation.');
|
|
119
|
+
const args = { target, agent };
|
|
120
|
+
if (body.classes !== undefined) {
|
|
121
|
+
if (!body.classes || typeof body.classes !== 'object' || Array.isArray(body.classes)) throw new Refusal(400, 'classes: { read, write, destructive }, each yes, ask or no.');
|
|
122
|
+
for (const [k, v] of Object.entries(body.classes)) if (!CLASSES.includes(k) || !(v === null || GOES.includes(v))) throw new Refusal(400, 'classes: { read, write, destructive }, each yes, ask or no.');
|
|
123
|
+
args.classes = body.classes;
|
|
124
|
+
}
|
|
125
|
+
if (body.tools !== undefined) {
|
|
126
|
+
if (!body.tools || typeof body.tools !== 'object' || Array.isArray(body.tools)) throw new Refusal(400, 'tools: { name: yes, ask or no }.');
|
|
127
|
+
for (const v of Object.values(body.tools)) if (!(v === null || GOES.includes(v))) throw new Refusal(400, 'tools: { name: yes, ask or no }.');
|
|
128
|
+
args.tools = body.tools;
|
|
129
|
+
}
|
|
130
|
+
if (body.askOpen !== undefined) args.askOpen = body.askOpen === null ? null : body.askOpen === true;
|
|
131
|
+
const r = await tethers.act('mcp-rights', args, '');
|
|
132
|
+
if (!r.ok) throw new Refusal(400, r.text);
|
|
133
|
+
return r.data;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* NEW TOOLS SEEN (Tether 0.1.0-next.57): a tool new on its server, or whose annotations changed, is `fresh` -- asked
|
|
137
|
+
even at All until a person approves one call of it or marks it seen here. { target, tools? } (absent: every fresh
|
|
138
|
+
tool of that server) -> { seen: [names] }. A Tether without the op says so. */
|
|
139
|
+
export async function mcpSeen({ tethers }, body = {}) {
|
|
140
|
+
const target = String(body.target ?? '');
|
|
141
|
+
const t = tethers.store().target(target);
|
|
142
|
+
if (!t || t.kind !== 'mcp') throw new Refusal(404, 'No such MCP server.');
|
|
143
|
+
if (body.tools !== undefined && (!Array.isArray(body.tools) || body.tools.some((x) => typeof x !== 'string'))) throw new Refusal(400, 'tools: a list of tool names.');
|
|
144
|
+
const r = await tethers.act('mcp-seen', { target, ...(body.tools ? { tools: body.tools } : {}) }, '');
|
|
145
|
+
if (!r.ok) throw new Refusal(/there is no mcp-seen/.test(r.text ?? '') ? 501 : 400, /there is no mcp-seen/.test(r.text ?? '') ? 'This Tether cannot mark tools as seen yet: update it.' : r.text);
|
|
146
|
+
return { seen: Array.isArray(r.data?.seen) ? r.data.seen : [] };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* A server taken away: its key goes with it (only the one kept for it here). */
|
|
150
|
+
export function forgetKey(secrets, target) {
|
|
151
|
+
const ref = target?.kind === 'mcp' ? String(target.secrets?.token ?? '') : '';
|
|
152
|
+
if (!ref || ref !== keyName(target.id)) return;
|
|
153
|
+
try { secrets?.remove(ref); } catch { /* not there */ }
|
|
154
|
+
}
|
package/server/settingswords.mjs
CHANGED
|
@@ -142,6 +142,22 @@ export const SETTINGS_WORDS_FR = {
|
|
|
142
142
|
'set-as-group': 'Groupe', 'set-as-group-make': 'Créez-en un dans la vue « Groupes ».',
|
|
143
143
|
'set-as-files': 'Fichiers', 'set-as-browse': 'Parcourir',
|
|
144
144
|
'set-cx-mail': 'boîte mail', 'set-cx-calendar': 'agenda', 'set-cx-contacts': 'carnet d\'adresses', 'set-cx-files': 'dossier en ligne',
|
|
145
|
+
/* External MCP servers (mcpx.mjs): the form, their tools, the rights in their own words. */
|
|
146
|
+
'set-na-k-mcp': 'Serveur MCP', 'set-cx-mcp': 'serveur MCP',
|
|
147
|
+
'set-mcp-head': 'Ajouter un serveur MCP', 'set-mcp-hint': 'Ses outils pour vos IA ; la clé reste ici.',
|
|
148
|
+
'set-mcp-name': 'Nom', 'set-mcp-url': 'Adresse', 'set-mcp-key': 'Clé', 'set-mcp-add': 'Ajouter',
|
|
149
|
+
'set-mcp-paste': 'Coller une configuration', 'set-mcp-paste-hint': 'Clés en secrets : ${NOM}. Aucun programme.',
|
|
150
|
+
'set-mcp-import': 'Importer', 'set-mcp-tools': 'Outils', 'set-mcp-refresh': 'Actualiser', 'set-mcp-seen': 'Marquer comme vu',
|
|
151
|
+
'set-mcp-none': 'Aucun outil listé pour l\'instant.', 'set-mcp-unreached': 'Injoignable : {why}', 'set-mcp-open': 'atteint l\'extérieur',
|
|
152
|
+
'set-mcp-fresh': 'Nouveau : demandé jusqu\'à ce que vous l\'ayez vu',
|
|
153
|
+
'set-mcp-c-read': 'Lecture seule', 'set-mcp-c-write': 'Écrit', 'set-mcp-c-destructive': 'Supprime ou écrase',
|
|
154
|
+
'set-mcp-custom': 'Droits personnalisés', 'set-mcp-yes': 'Oui', 'set-mcp-ask': 'Demander', 'set-mcp-no': 'Non',
|
|
155
|
+
'set-rt-mcp-none': 'Coupé', 'set-rt-mcp-watch': 'Lit', 'set-rt-mcp-propose': 'Tout, approuvé', 'set-rt-mcp-free': 'Tout', 'set-rt-mcp-frame': 'Personnalisé',
|
|
156
|
+
'set-rt-mcp-pick-none': 'Coupé', 'set-rt-mcp-pick-watch': 'Lecture', 'set-rt-mcp-pick-propose': 'Tout avec accord',
|
|
157
|
+
'set-rt-mcp-pick-free': 'Tout', 'set-rt-mcp-pick-frame': 'Personnalisé',
|
|
158
|
+
'set-rt-mcp-hint-none': 'Pas donné', 'set-rt-mcp-hint-watch': 'Ses outils en lecture seule',
|
|
159
|
+
'set-rt-mcp-hint-propose': 'Tous les outils, chaque modification approuvée', 'set-rt-mcp-hint-free': 'Tous les outils ; suppressions demandées',
|
|
160
|
+
'set-rt-mcp-hint-frame': 'Votre choix pour chaque sorte d\'outil',
|
|
145
161
|
'set-cx-ssh': 'machine', 'set-cx-computer': 'ordinateur', 'set-cx-browser': 'navigateur', 'set-cx-screen': 'écran', 'set-cx-device': 'appareil',
|
|
146
162
|
'set-acc-share': 'Partager avec un appareil…', 'set-acc-in-assets': 'Ouvrir dans Assets', 'set-acc-detach': 'Retirer',
|
|
147
163
|
'set-acc-used-1': 'Utilisé par 1 conversation : mettez « Aucun »', 'set-acc-used-n': 'Utilisé par {n} conversations : mettez « Aucun »',
|
package/web/app.js
CHANGED
|
@@ -3947,7 +3947,7 @@
|
|
|
3947
3947
|
line.append(summary, el('pre', '', rest));
|
|
3948
3948
|
return line;
|
|
3949
3949
|
}
|
|
3950
|
-
const kindIcon = (kind) => { const name = kind === 'send' || kind === 'demandes' ? 'arrow-up' : kind === 'box' ? 'mic' : kind === 'device' ? 'smartphone' : ASSET_KINDS.find((k) => k[0] === kind)?.[2]; const holder = el('span', 'as-ic'); holder.dataset.kind = kind; const svg = kind === 'list' ? listIcon() : name ? window.clavariumIcons?.icon(name) : null; if (svg) holder.append(svg); return holder; };
|
|
3950
|
+
const kindIcon = (kind) => { const name = kind === 'send' || kind === 'demandes' ? 'arrow-up' : kind === 'box' ? 'mic' : kind === 'device' ? 'smartphone' : kind === 'mcp' ? 'link' : ASSET_KINDS.find((k) => k[0] === kind)?.[2]; const holder = el('span', 'as-ic'); holder.dataset.kind = kind; const svg = kind === 'list' ? listIcon() : name ? window.clavariumIcons?.icon(name) : null; if (svg) holder.append(svg); return holder; };
|
|
3951
3951
|
/* A list (Binder's): the set has no such sign, so it is drawn here, in its style. */
|
|
3952
3952
|
function listIcon() {
|
|
3953
3953
|
const SVG = 'http://www.w3.org/2000/svg';
|
|
@@ -4916,7 +4916,7 @@
|
|
|
4916
4916
|
await refresh();
|
|
4917
4917
|
}
|
|
4918
4918
|
/* The plain word for a Tether target's kind, in a Connexion's sub-line. */
|
|
4919
|
-
const CONNEXION_SUB = { mail: 'mailbox', calendar: 'calendar', contacts: 'address book', files: 'online folder', ssh: 'machine', computer: 'computer', browser: 'browser', screen: 'screen', device: 'device' };
|
|
4919
|
+
const CONNEXION_SUB = { mail: 'mailbox', calendar: 'calendar', contacts: 'address book', files: 'online folder', ssh: 'machine', computer: 'computer', browser: 'browser', screen: 'screen', device: 'device', mcp: 'MCP server' };
|
|
4920
4920
|
const connexionSub = (kind) => (kind in CONNEXION_SUB ? uiSaid(`set-cx-${kind}`, CONNEXION_SUB[kind]) : kind);
|
|
4921
4921
|
const connexionOwnSub = (kind) => (tetherCat(kind) === 'acces' ? connexionSub(kind) : '');
|
|
4922
4922
|
/* Give a Tether target to a conversation at a level, or take it back (none). */
|
|
@@ -5034,7 +5034,7 @@
|
|
|
5034
5034
|
const newKinds = () => [['folder', uiSaid('set-na-k-folder', 'Folder')], ['git', uiSaid('set-na-k-git', 'Git repository')], ['mailbox', uiSaid('set-na-k-mailbox', 'Mailbox')],
|
|
5035
5035
|
['agenda', uiSaid('set-na-k-agenda', 'Calendar')], ['contacts', uiSaid('set-na-k-contacts', 'Contacts')], ['secret', uiSaid('set-na-k-secret', 'Secret')], ['list', uiSaid('set-na-k-list', LISTS_WORD.one)],
|
|
5036
5036
|
/* Step 4: a voice box (paired with a code) and a device (the share sheet, its code) are two kinds of it too. */
|
|
5037
|
-
['box', uiSaid('set-na-k-box', 'Voice box')], ['device', uiSaid('set-na-k-device', 'Device')]]
|
|
5037
|
+
['box', uiSaid('set-na-k-box', 'Voice box')], ['device', uiSaid('set-na-k-device', 'Device')], ['mcp', uiSaid('set-na-k-mcp', 'MCP server')]]
|
|
5038
5038
|
.map(([kind, label]) => ({ kind, label, icon: kindIcon(kind) }));
|
|
5039
5039
|
/* A voice box's steps after its kind (the flow: "A NEW VOICE BOX", with the boxes), and the one under way. */
|
|
5040
5040
|
const BOX_STEPS = ['where', 'plug', 'pair'];
|
|
@@ -5131,6 +5131,70 @@
|
|
|
5131
5131
|
await repaintAsset();
|
|
5132
5132
|
return true;
|
|
5133
5133
|
}
|
|
5134
|
+
/* AN EXTERNAL MCP SERVER (mcpx.mjs, Tether's mcp target): its address, its tools each with its kind, « Refresh »;
|
|
5135
|
+
a new tool marked until seen (« Mark as seen »); then, for each conversation at Custom, what each kind may do. */
|
|
5136
|
+
const MCP_CLASS = { read: ['set-mcp-c-read', 'Reads only'], write: ['set-mcp-c-write', 'Writes'], destructive: ['set-mcp-c-destructive', 'Deletes or overwrites'] };
|
|
5137
|
+
const mcpClass = (c) => uiSaid(...(MCP_CLASS[c] ?? MCP_CLASS.destructive));
|
|
5138
|
+
function mcpParts(t) {
|
|
5139
|
+
const out = [];
|
|
5140
|
+
if (t.config?.url) out.push(el('p', 'hint mcp-url', t.config.url));
|
|
5141
|
+
const tools = Array.isArray(t.catalog?.tools) ? t.catalog.tools : [];
|
|
5142
|
+
const refresh = el('button', '', uiSaid('set-mcp-refresh', 'Refresh')); refresh.type = 'button'; refresh.dataset.act = 'mcp-refresh';
|
|
5143
|
+
refresh.addEventListener('click', async () => {
|
|
5144
|
+
refresh.disabled = true; $(asErrId()).hidden = true;
|
|
5145
|
+
try { const r = await post(`${API}/tether/mcp-refresh`, { target: t.id }); if (r?.error) sayAsErr(new Error(r.error)); }
|
|
5146
|
+
catch (error) { sayAsErr(error); }
|
|
5147
|
+
await repaintAsset();
|
|
5148
|
+
});
|
|
5149
|
+
const fresh = tools.filter((x) => x.fresh === true);
|
|
5150
|
+
const seen = fresh.length ? el('button', '', uiSaid('set-mcp-seen', 'Mark as seen')) : null;
|
|
5151
|
+
if (seen) {
|
|
5152
|
+
seen.type = 'button'; seen.dataset.act = 'mcp-seen';
|
|
5153
|
+
seen.addEventListener('click', async () => {
|
|
5154
|
+
seen.disabled = true; $(asErrId()).hidden = true;
|
|
5155
|
+
try { await post(`${API}/tether/mcp-seen`, { target: t.id }); } catch (error) { sayAsErr(error); }
|
|
5156
|
+
await repaintAsset();
|
|
5157
|
+
});
|
|
5158
|
+
}
|
|
5159
|
+
const list = el('ul', 'mcp-tools');
|
|
5160
|
+
for (const x of tools) {
|
|
5161
|
+
const li = el('li', 'mcp-tool'); li.dataset.tool = x.name; li.dataset.class = x.class;
|
|
5162
|
+
const nm = el('span', 'mcp-tool-name', x.title || x.name);
|
|
5163
|
+
if (x.description) nm.title = x.description;
|
|
5164
|
+
const kind = el('small', 'mcp-tool-class', mcpClass(x.class)); kind.dataset.class = x.class;
|
|
5165
|
+
li.append(nm, kind);
|
|
5166
|
+
if (x.open && x.class !== 'read') li.append(el('small', 'hint', uiSaid('set-mcp-open', 'reaches outside')));
|
|
5167
|
+
if (x.fresh === true) li.append(el('small', 'mcp-tool-fresh', uiSaid('set-mcp-fresh', 'New — asked until you\'ve seen it')));
|
|
5168
|
+
list.append(li);
|
|
5169
|
+
}
|
|
5170
|
+
const body = [];
|
|
5171
|
+
if (t.catalog?.error) body.push(el('p', 'error', uiSaid('set-mcp-unreached', 'Not reached: {why}', { why: t.catalog.error })));
|
|
5172
|
+
body.push(tools.length ? list : el('p', 'hint', uiSaid('set-mcp-none', 'No tools listed yet.')));
|
|
5173
|
+
out.push({ title: uiSaid('set-mcp-tools', 'Tools'), extra: [seen, refresh], body, cls: 'as-mcp-tools' });
|
|
5174
|
+
/* Custom: each kind of tool Yes / Ask / No, for each conversation set to it. */
|
|
5175
|
+
const custom = Object.entries(t.grants ?? {}).filter(([id, g]) => id.startsWith('conversation:') && g?.level === 'frame');
|
|
5176
|
+
if (custom.length) {
|
|
5177
|
+
const goes = [['yes', uiSaid('set-mcp-yes', 'Yes')], ['ask', uiSaid('set-mcp-ask', 'Ask')], ['no', uiSaid('set-mcp-no', 'No')]].map(([value, label]) => ({ value, label }));
|
|
5178
|
+
const rows = custom.map(([id, g]) => {
|
|
5179
|
+
const box = el('div', 'mcp-custom'); box.dataset.conversation = id.slice('conversation:'.length);
|
|
5180
|
+
const who = conversations.find((c) => c.name === id.slice('conversation:'.length));
|
|
5181
|
+
box.append(el('b', '', (who && shownName(who)) || id.slice('conversation:'.length)));
|
|
5182
|
+
for (const c of ['read', 'write', 'destructive']) {
|
|
5183
|
+
const now = g.classes?.[c] ?? (c === 'read' ? 'yes' : 'ask');
|
|
5184
|
+
const line = el('div', 'mcp-custom-line'); line.dataset.class = c;
|
|
5185
|
+
line.append(el('span', '', mcpClass(c)), KIT.Segmented({ options: goes, value: now, label: mcpClass(c), onChange: async (go) => {
|
|
5186
|
+
$(asErrId()).hidden = true;
|
|
5187
|
+
try { await post(`${API}/tether/mcp-rights`, { target: t.id, agent: id, classes: { [c]: go } }); } catch (error) { sayAsErr(error); }
|
|
5188
|
+
await repaintAsset();
|
|
5189
|
+
} }));
|
|
5190
|
+
box.append(line);
|
|
5191
|
+
}
|
|
5192
|
+
return box;
|
|
5193
|
+
});
|
|
5194
|
+
out.push({ title: uiSaid('set-mcp-custom', 'Custom rights'), body: rows, cls: 'as-mcp-custom' });
|
|
5195
|
+
}
|
|
5196
|
+
return out;
|
|
5197
|
+
}
|
|
5134
5198
|
/* `detail`: where it is drawn. `from`: the conversation it is seen from ('' : the menu). `back`: the way back (a phone).
|
|
5135
5199
|
`tether`, `groups`: as the page read them. `gits`: the conversation's repositories (To send, beside). */
|
|
5136
5200
|
async function assetPanel(detail, chosen, { from = '', back, tether = {}, groups = [], gits = [] } = {}) {
|
|
@@ -5217,6 +5281,7 @@
|
|
|
5217
5281
|
box.append(lab, ta, row);
|
|
5218
5282
|
parts.push(box);
|
|
5219
5283
|
}
|
|
5284
|
+
if (t.kind === 'mcp') parts.push(...mcpParts(t));
|
|
5220
5285
|
if (chosen.siblings?.length) {
|
|
5221
5286
|
const a = el('p', 'as-assoc hint');
|
|
5222
5287
|
a.append(el('b', '', 'Linked'), document.createTextNode(` to the same account: ${chosen.siblings.join(', ')}. They share the account's sign-in (kept on the server); rights are given separately.`));
|
|
@@ -5315,7 +5380,8 @@
|
|
|
5315
5380
|
parts.push({ title: 'Settings', extra: [edit], body }, gitHolder);
|
|
5316
5381
|
}
|
|
5317
5382
|
if (t) {
|
|
5318
|
-
|
|
5383
|
+
/* An MCP server's rights say themselves (Off / Read / All with approval / All / Custom): no levels' words under them. */
|
|
5384
|
+
if (t.kind !== 'mcp') parts.push(el('p', 'hint', 'A level is: watch (read only), propose (asks before each action), frame (acts within its rules, asks for the rest), free (acts, kept in the journal).'));
|
|
5319
5385
|
/* Calls waiting for a person (propose/frame). Accept or refuse each. */
|
|
5320
5386
|
const waiting = (Array.isArray(tether.approvals) ? tether.approvals : []).filter((q) => (q.target?.id ?? q.target) === t.id);
|
|
5321
5387
|
if (waiting.length) {
|
|
@@ -8789,6 +8855,34 @@
|
|
|
8789
8855
|
btn.disabled = false; btn.textContent = was;
|
|
8790
8856
|
await paintAssets();
|
|
8791
8857
|
});
|
|
8858
|
+
/* ADD AN MCP SERVER (mcpx.mjs): its name, address and key -- the key kept on the server, never shown again. Or a
|
|
8859
|
+
pasted { "mcpServers": ... } block, its keys named as secrets. */
|
|
8860
|
+
$('mcp-new').addEventListener('submit', async (event) => {
|
|
8861
|
+
event.preventDefault();
|
|
8862
|
+
$('mcp-error').hidden = true;
|
|
8863
|
+
const btn = $('mcp-add'); btn.disabled = true;
|
|
8864
|
+
try {
|
|
8865
|
+
const made = await post(`${API}/tether/mcp-add`, { name: $('mcp-name').value.trim(), url: $('mcp-url').value.trim(), key: $('mcp-key').value });
|
|
8866
|
+
$('mcp-new').reset();
|
|
8867
|
+
await assetMade($('mcp-box'), { key: `connexion:${made.id}`, name: made.title, note: made.error ? uiSaid('set-mcp-unreached', 'Not reached: {why}', { why: made.error }) : '',
|
|
8868
|
+
give: (to, level) => grantTarget(made.id, to, targetLevel(level)) });
|
|
8869
|
+
} catch (error) { $('mcp-error').textContent = error.message; $('mcp-error').hidden = false; }
|
|
8870
|
+
$('mcp-key').value = '';
|
|
8871
|
+
btn.disabled = false;
|
|
8872
|
+
await paintAssets();
|
|
8873
|
+
});
|
|
8874
|
+
$('mcp-import').addEventListener('click', async () => {
|
|
8875
|
+
$('mcp-error').hidden = true;
|
|
8876
|
+
const btn = $('mcp-import'); btn.disabled = true;
|
|
8877
|
+
try {
|
|
8878
|
+
const made = await post(`${API}/tether/mcp-import`, { config: $('mcp-config').value });
|
|
8879
|
+
$('mcp-config').value = '';
|
|
8880
|
+
const first = made.servers?.[0];
|
|
8881
|
+
if (first) await assetMade($('mcp-box'), { key: `connexion:${first.id}`, name: first.title, give: (to, level) => Promise.all(made.servers.map((x) => grantTarget(x.id, to, targetLevel(level)))) });
|
|
8882
|
+
} catch (error) { $('mcp-error').textContent = error.message; $('mcp-error').hidden = false; }
|
|
8883
|
+
btn.disabled = false;
|
|
8884
|
+
await paintAssets();
|
|
8885
|
+
});
|
|
8792
8886
|
const connexionKinds = () => [['connexion-mail', 'mail'], ['connexion-calendar', 'calendar'], ['connexion-contacts', 'contacts']].filter(([id]) => $(id).checked && !$(id).disabled).map(([, k]) => k);
|
|
8793
8887
|
$('connexion-open').addEventListener('click', async () => {
|
|
8794
8888
|
$('connexion-error').hidden = true;
|
package/web/index.html
CHANGED
|
@@ -879,6 +879,24 @@
|
|
|
879
879
|
<p id="connexion-error" class="error" role="alert" hidden></p>
|
|
880
880
|
</form>
|
|
881
881
|
</div>
|
|
882
|
+
<div id="mcp-box" class="as-form" data-kind="mcp"><h3 data-said="set-mcp-head">Add an MCP server</h3>
|
|
883
|
+
<p class="hint" data-said="set-mcp-hint">Its tools, given to the AIs you choose. The key stays on the server.</p>
|
|
884
|
+
<form id="mcp-new" autocomplete="off">
|
|
885
|
+
<label for="mcp-name" data-said="set-mcp-name">Name</label>
|
|
886
|
+
<input id="mcp-name" maxlength="120" spellcheck="false" placeholder="CRM" required>
|
|
887
|
+
<label for="mcp-url" data-said="set-mcp-url">Address</label>
|
|
888
|
+
<input id="mcp-url" type="url" spellcheck="false" autocapitalize="off" placeholder="https://mcp.example.com/mcp" required>
|
|
889
|
+
<label for="mcp-key" data-said="set-mcp-key">Key</label>
|
|
890
|
+
<input id="mcp-key" type="password" autocomplete="new-password" placeholder="optional, never shown again">
|
|
891
|
+
<div class="row"><button type="submit" id="mcp-add" class="primary" data-said="set-mcp-add">Add</button></div>
|
|
892
|
+
<details class="more"><summary data-said="set-mcp-paste">Paste a configuration</summary>
|
|
893
|
+
<textarea id="mcp-config" rows="6" spellcheck="false" autocapitalize="off" aria-label="mcpServers configuration" placeholder='{ "mcpServers": { "crm": { "url": "https://mcp.example.com/mcp", "headers": { "Authorization": "Bearer ${CRM_KEY}" } } } }'></textarea>
|
|
894
|
+
<p class="hint" data-said="set-mcp-paste-hint">Keys named as secrets: ${NAME}. No programs to start.</p>
|
|
895
|
+
<div class="row"><button type="button" id="mcp-import" data-said="set-mcp-import">Import</button></div>
|
|
896
|
+
</details>
|
|
897
|
+
<p id="mcp-error" class="error" role="alert" hidden></p>
|
|
898
|
+
</form>
|
|
899
|
+
</div>
|
|
882
900
|
<div id="list-box" class="as-form" data-kind="list"><h3 id="listnew-head">New binder</h3>
|
|
883
901
|
<form id="list-new" autocomplete="off">
|
|
884
902
|
<label for="listnew-template">Template</label>
|
package/web/kit.js
CHANGED
|
@@ -470,11 +470,13 @@
|
|
|
470
470
|
secret: { conversation: ['none', 'use'] },
|
|
471
471
|
list: { conversation: ['none', 'read', 'write'] },
|
|
472
472
|
connexion: { conversation: ['none', 'watch', 'propose', 'frame', 'free'] },
|
|
473
|
+
/* An external MCP server (Tether's mcp target): the same levels, said by what they let its tools do. */
|
|
474
|
+
mcp: { conversation: ['none', 'watch', 'propose', 'free', 'frame'] },
|
|
473
475
|
conversation: { device: ['none', 'terminal', 'chat', 'both'] },
|
|
474
476
|
};
|
|
475
477
|
const HOLDERS = ['conversation', 'device'];
|
|
476
478
|
/* The table's key of an asset: a live link is a connexion, whatever it links; else its kind. */
|
|
477
|
-
const rightsKey = (a) => (a?.live || a?.target ? 'connexion' : a?.kind ?? '');
|
|
479
|
+
const rightsKey = (a) => (a?.target?.kind === 'mcp' ? 'mcp' : a?.live || a?.target ? 'connexion' : a?.kind ?? '');
|
|
478
480
|
const rightsFor = (key, holder = 'conversation') => [...(RIGHTS[key]?.[HOLDERS.includes(holder) ? holder : 'conversation'] ?? [])];
|
|
479
481
|
/* The lightest real level (what "give the group" gives): the first after none. */
|
|
480
482
|
const lightest = (key, holder) => rightsFor(key, holder)[1] ?? null;
|
|
@@ -506,12 +508,22 @@
|
|
|
506
508
|
return say(`set-rt-${form === 'badge' ? '' : `${form}-`}${level in RIGHT_LEVELS ? level : 'none'}`, l[form] ?? l.badge);
|
|
507
509
|
};
|
|
508
510
|
|
|
511
|
+
/* AN MCP SERVER'S LEVELS, in its own words: Off, Read, All with approval, All, Custom (Tether's table). */
|
|
512
|
+
const MCP_WORDS = {
|
|
513
|
+
none: { pick: 'Off', badge: 'Off', hint: 'Not given' },
|
|
514
|
+
watch: { pick: 'Read', badge: 'Reads', hint: 'Its read-only tools' },
|
|
515
|
+
propose: { pick: 'All with approval', badge: 'All, approved', hint: 'Every tool, you approve each change' },
|
|
516
|
+
free: { pick: 'All', badge: 'All', hint: 'Every tool; deletes still asked' },
|
|
517
|
+
frame: { pick: 'Custom', badge: 'Custom', hint: 'Your choice for each kind of tool' },
|
|
518
|
+
};
|
|
519
|
+
const wordOf = (key, level, form) => (key === 'mcp' && MCP_WORDS[level] ? say(`set-rt-mcp-${form === 'badge' ? '' : `${form}-`}${level}`, MCP_WORDS[level][form] ?? MCP_WORDS[level].pick) : rightWord(level, form));
|
|
520
|
+
|
|
509
521
|
/* A RIGHT, READ: a small pill, its tone by what it lets do. `level`, or `value` as kept (with its `key`, `holder`). */
|
|
510
522
|
function RightsBadge(opts = {}) {
|
|
511
523
|
const level = levelOf(opts);
|
|
512
|
-
const b = node('span', `kit-rights${opts.cls ? ` ${opts.cls}` : ''}`,
|
|
524
|
+
const b = node('span', `kit-rights${opts.cls ? ` ${opts.cls}` : ''}`, wordOf(opts.key, level, 'badge'));
|
|
513
525
|
b.dataset.level = level; b.dataset.tone = RIGHT_LEVELS[level].tone;
|
|
514
|
-
b.title =
|
|
526
|
+
b.title = wordOf(opts.key, level, 'hint');
|
|
515
527
|
return b;
|
|
516
528
|
}
|
|
517
529
|
|
|
@@ -524,7 +536,7 @@
|
|
|
524
536
|
const levels = rightsFor(key, holder);
|
|
525
537
|
if (!levels.length) return null;
|
|
526
538
|
const now = level ?? rightOf(key, value, holder);
|
|
527
|
-
const options = levels.map((l) => ({ value: l, label:
|
|
539
|
+
const options = levels.map((l) => ({ value: l, label: wordOf(key, l, 'pick') }));
|
|
528
540
|
let held = now;
|
|
529
541
|
const pick = Segmented({ options, value: now, label, onChange: async (l) => {
|
|
530
542
|
if (l === held) return;
|
|
@@ -537,7 +549,7 @@
|
|
|
537
549
|
pick.dataset.key = key; pick.dataset.holder = holder;
|
|
538
550
|
const same = key === 'connexion' ? RIGHT_SAME[kind] : null;
|
|
539
551
|
for (const b of pick.children) {
|
|
540
|
-
b.title =
|
|
552
|
+
b.title = wordOf(key, b.dataset.value, 'hint');
|
|
541
553
|
const as = same?.[b.dataset.value];
|
|
542
554
|
if (as) { const w = rightWord(as, 'pick'); b.append(node('small', 'kit-rights-same', `= ${w}`)); b.title = `${b.title} (= ${w})`; }
|
|
543
555
|
}
|
|
@@ -876,7 +888,7 @@
|
|
|
876
888
|
window.clavariumKit = {
|
|
877
889
|
collectModel,
|
|
878
890
|
AssetIcon, AssetCard, Holders, MemberStack, memberStackModel, roomPatch, RightsBadge, RightsPicker, AssetList, assetListModel, AccessList, accessModel, AssetPanel, AssetDelete, RIGHT_SAME,
|
|
879
|
-
ASSET_ICONS, ASSET_KINDS, RIGHTS, RIGHT_LEVELS, rightsKey, rightsFor, rightOf, storedOf, lightest, rightWord,
|
|
891
|
+
ASSET_ICONS, ASSET_KINDS, RIGHTS, RIGHT_LEVELS, MCP_WORDS, rightsKey, rightsFor, rightOf, storedOf, lightest, rightWord,
|
|
880
892
|
shareSlices, ShareDonut, ShareKey,
|
|
881
893
|
STATES, STATE_WORDS, stateOf, hueOf, initialOf, IDLE_MS, idleFor, idleWords, tidyList,
|
|
882
894
|
setWords(fn) { if (typeof fn === 'function') words = fn; },
|
package/web/style.css
CHANGED
|
@@ -2159,6 +2159,17 @@ details.commit > pre { margin: 6px 0 0 1.6em; white-space: pre-wrap; overflow-wr
|
|
|
2159
2159
|
.demandes-box { display: flex; flex-direction: column; gap: 10px; }
|
|
2160
2160
|
.as-signature { display: flex; flex-direction: column; gap: 6px; margin: .6rem 0; }
|
|
2161
2161
|
.as-signature textarea { width: 100%; font: inherit; }
|
|
2162
|
+
/* An external MCP server (app.js mcpParts): its tools, each with its kind; a new one marked; Custom rights by kind. */
|
|
2163
|
+
.mcp-url { overflow-wrap: anywhere; }
|
|
2164
|
+
.mcp-tools { list-style: none; margin: .3rem 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
|
|
2165
|
+
.mcp-tool { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; padding: 4px 0; border-bottom: 1px solid var(--line); }
|
|
2166
|
+
.mcp-tool-name { font-weight: 600; overflow-wrap: anywhere; }
|
|
2167
|
+
.mcp-tool-class { color: var(--ink2); }
|
|
2168
|
+
.mcp-tool-class[data-class="destructive"] { color: var(--danger); }
|
|
2169
|
+
.mcp-tool-fresh { color: var(--warn); }
|
|
2170
|
+
.mcp-custom { display: flex; flex-direction: column; gap: 4px; margin: .4rem 0 .8rem; }
|
|
2171
|
+
.mcp-custom-line { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px; }
|
|
2172
|
+
#mcp-config { width: 100%; font: .8125rem var(--mono, monospace); }
|
|
2162
2173
|
.as-drag-ghost { position: fixed; z-index: 90; pointer-events: none; opacity: .9; box-shadow: 0 12px 30px -10px rgba(0,0,0,.5); border-radius: 10px; background: var(--panel); }
|
|
2163
2174
|
.kit-aslist-group.as-drop-over { outline: 2px dashed var(--brass); outline-offset: 2px; border-radius: 10px; background: color-mix(in srgb, var(--brass) 7%, transparent); }
|
|
2164
2175
|
body.as-dragging { user-select: none; -webkit-user-select: none; }
|