@zackbart/connecta 0.33.0 → 0.34.0
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/CHANGELOG.md +35 -0
- package/README.md +99 -128
- package/SECURITY.md +1 -1
- package/bin/version.mjs +1 -1
- package/dist/activity.d.ts +3 -0
- package/dist/branding.d.ts +7 -0
- package/dist/branding.js +76 -9
- package/dist/catalog-service.js +15 -1
- package/dist/config-value-sources.js +6 -1
- package/dist/config.d.ts +13 -2
- package/dist/config.js +36 -2
- package/dist/connector-caller.js +2 -1
- package/dist/describe-config.d.ts +3 -1
- package/dist/describe-config.js +1 -0
- package/dist/execute.js +1 -11
- package/dist/fix-prompt.js +2 -2
- package/dist/index.js +1 -0
- package/dist/membership.d.ts +2 -0
- package/dist/oauth-callback-outcome.js +2 -2
- package/dist/operator-ui/agents.d.ts +1 -1
- package/dist/operator-ui/agents.js +16 -16
- package/dist/operator-ui/config-summary.js +562 -0
- package/dist/operator-ui/connection-map.js +375 -0
- package/dist/operator-ui/endpoints.js +44 -15
- package/dist/operator-ui/fix-prompts.js +1 -1
- package/dist/operator-ui/generated.js +4 -4
- package/dist/operator-ui/member-page.js +13 -28
- package/dist/operator-ui/overview.js +134 -0
- package/dist/operator-ui/view.d.ts +27 -22
- package/dist/operator-ui/view.js +104 -88
- package/dist/page-styles.js +5 -3
- package/dist/providers/_shared/google/workspace.d.ts +6 -4
- package/dist/providers/_shared/google/workspace.js +6 -2
- package/dist/providers/ccb/index.js +1 -1
- package/dist/providers/docs/index.js +1 -1
- package/dist/providers/docs/skill.generated.js +2 -2
- package/dist/providers/drive/index.js +1 -1
- package/dist/providers/drive/skill.generated.js +2 -2
- package/dist/providers/forms/index.js +1 -1
- package/dist/providers/forms/skill.generated.js +2 -2
- package/dist/providers/gmail/index.js +1 -1
- package/dist/providers/gmail/skill.generated.js +2 -2
- package/dist/providers/mixpanel/skill.generated.js +3 -3
- package/dist/providers/notion/skill.generated.js +1 -1
- package/dist/providers/overflow/index.js +1 -1
- package/dist/providers/overflow/skill.generated.js +2 -2
- package/dist/providers/planning-center/index.js +1 -1
- package/dist/providers/planning-center/skill.generated.js +4 -4
- package/dist/providers/revenuecat/skill.generated.js +2 -2
- package/dist/providers/sheets/index.js +1 -1
- package/dist/providers/sheets/skill.generated.js +2 -2
- package/dist/providers/slides/index.js +1 -1
- package/dist/providers/slides/skill.generated.js +2 -2
- package/dist/providers/tithely/index.js +1 -1
- package/dist/providers/vercel/index.js +1 -1
- package/dist/providers/vercel/skill.generated.js +1 -1
- package/dist/registry.js +15 -0
- package/dist/routes/mcp.js +20 -13
- package/dist/routes/oauth.js +2 -2
- package/dist/skills.js +10 -0
- package/dist/storage/sql.js +5 -1
- package/dist/types.d.ts +18 -7
- package/dist/ui.js +9 -4
- package/dist/usage-guide.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/README.md +2 -2
- package/documentation/architecture.md +18 -13
- package/documentation/auth.md +183 -67
- package/documentation/code-mode.md +23 -5
- package/documentation/deploying.md +5 -1
- package/documentation/integrating.md +9 -6
- package/documentation/meta-tools.md +36 -22
- package/documentation/migration-0.33.md +21 -13
- package/documentation/migration-0.34.md +67 -0
- package/documentation/operator-tests.md +46 -20
- package/documentation/operator-ui.md +294 -104
- package/examples/worker/AGENTS.md +15 -9
- package/examples/worker/README.md +41 -14
- package/examples/worker/src/connecta.config.ts +5 -4
- package/examples/worker/wrangler.jsonc +1 -1
- package/package.json +19 -19
- package/templates/node/AGENTS.md +13 -7
- package/templates/node/README.md +14 -11
- package/templates/node/package.json +1 -1
- package/templates/node/src/connecta.config.ts +2 -2
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { endpointRows, hueOf, initialsOf } from "./endpoints.js";
|
|
2
|
+
import { connectorVerdict } from "./view.js";
|
|
3
|
+
/* Layout. One coordinate system, scaled by the SVG's viewBox. */
|
|
4
|
+
const MAP_WIDTH = 1000;
|
|
5
|
+
const COLUMN_X = [16, 280, 544];
|
|
6
|
+
const COLUMN_WIDTH = [196, 196, 196];
|
|
7
|
+
/** Each connector's read and write tools sit in a fourth column, joined by a straight link. */
|
|
8
|
+
const TOOLS_X = 808;
|
|
9
|
+
const TOOLS_WIDTH = 176;
|
|
10
|
+
const NODE_HEIGHT = 54;
|
|
11
|
+
const NODE_GAP = 12;
|
|
12
|
+
const HEAD = 40;
|
|
13
|
+
const FOOT = 12;
|
|
14
|
+
/** Characters a node's name keeps before it is cut with an ellipsis. */
|
|
15
|
+
const LABEL_LIMIT = [18, 17, 17];
|
|
16
|
+
/** A read-only endpoint's name shares its row with the read-only chip. */
|
|
17
|
+
const READ_ONLY_LABEL_LIMIT = 12;
|
|
18
|
+
/** "a", "a and b", "a, b and c". */
|
|
19
|
+
export function joinList(items) {
|
|
20
|
+
if (items.length <= 1)
|
|
21
|
+
return items[0] ?? "";
|
|
22
|
+
return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
|
|
23
|
+
}
|
|
24
|
+
function plural(count, one, many = `${one}s`) {
|
|
25
|
+
return `${count} ${count === 1 ? one : many}`;
|
|
26
|
+
}
|
|
27
|
+
/** Cut a name to `limit` characters, ending in an ellipsis when it was longer. */
|
|
28
|
+
export function truncate(text, limit) {
|
|
29
|
+
return text.length <= limit ? text : `${text.slice(0, limit - 1).trimEnd()}…`;
|
|
30
|
+
}
|
|
31
|
+
/** Where an endpoint's row opens on the Endpoints page. */
|
|
32
|
+
export function endpointHref(path) {
|
|
33
|
+
return `/endpoints#${path.replace(/^\//, "")}`;
|
|
34
|
+
}
|
|
35
|
+
/** The endpoint path an Endpoints page hash names, or null. */
|
|
36
|
+
export function endpointFromHash(hash) {
|
|
37
|
+
const value = hash.replace(/^#/, "");
|
|
38
|
+
return value.startsWith("mcp") ? `/${value}` : null;
|
|
39
|
+
}
|
|
40
|
+
function connectorHref(id, healthy) {
|
|
41
|
+
return `/connectors/${encodeURIComponent(id)}#${healthy ? "tools" : "connection"}`;
|
|
42
|
+
}
|
|
43
|
+
function tokenWorks(agent) {
|
|
44
|
+
return agent?.token.state === "issued";
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* People in the order the contract first lists them (plain /mcp's
|
|
48
|
+
* maintainers, then each pool's members), then every declared agent, then any
|
|
49
|
+
* agent a pool lists that is not declared. Plain /mcp admits no agents.
|
|
50
|
+
*/
|
|
51
|
+
function callersOf(contract, rows) {
|
|
52
|
+
const people = new Map();
|
|
53
|
+
const agents = new Map();
|
|
54
|
+
for (const agent of contract.agents) {
|
|
55
|
+
agents.set(agent.name, { id: `agent:${agent.name}`, kind: "agent", name: agent.name, agent, endpoints: [] });
|
|
56
|
+
}
|
|
57
|
+
const memberLists = rows.map((row) => [
|
|
58
|
+
row,
|
|
59
|
+
row.pool === null
|
|
60
|
+
? (contract.root?.maintainers ?? [])
|
|
61
|
+
: (contract.pools.find((pool) => pool.name === row.pool)?.members ?? []),
|
|
62
|
+
]);
|
|
63
|
+
for (const [row, members] of memberLists) {
|
|
64
|
+
for (const member of members) {
|
|
65
|
+
if (member.kind === "agent") {
|
|
66
|
+
if (row.pool === null)
|
|
67
|
+
continue;
|
|
68
|
+
let caller = agents.get(member.name);
|
|
69
|
+
if (!caller) {
|
|
70
|
+
caller = { id: `agent:${member.name}`, kind: "agent", name: member.name, agent: null, endpoints: [] };
|
|
71
|
+
agents.set(member.name, caller);
|
|
72
|
+
}
|
|
73
|
+
if (!caller.endpoints.includes(row))
|
|
74
|
+
caller.endpoints.push(row);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const id = `person:${member.namespace}:${member.id}`;
|
|
78
|
+
let caller = people.get(id);
|
|
79
|
+
if (!caller) {
|
|
80
|
+
caller = { id, kind: "person", name: member.label ?? member.id, agent: null, endpoints: [] };
|
|
81
|
+
people.set(id, caller);
|
|
82
|
+
}
|
|
83
|
+
if (!caller.endpoints.includes(row))
|
|
84
|
+
caller.endpoints.push(row);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return [...people.values(), ...agents.values()];
|
|
88
|
+
}
|
|
89
|
+
/** Unique live tools a set of endpoints serves, by address. */
|
|
90
|
+
function servedTools(rows) {
|
|
91
|
+
const connectors = new Set();
|
|
92
|
+
const tools = new Map();
|
|
93
|
+
for (const row of rows) {
|
|
94
|
+
for (const group of row.groups) {
|
|
95
|
+
connectors.add(group.config.id);
|
|
96
|
+
for (const tool of group.tools)
|
|
97
|
+
tools.set(tool.address, tool.classification);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const write = [...tools.values()].filter((value) => value === "write").length;
|
|
101
|
+
return { connectors, tools: tools.size, write };
|
|
102
|
+
}
|
|
103
|
+
export function toolsPhrase(tools, write) {
|
|
104
|
+
if (!tools)
|
|
105
|
+
return "no tools";
|
|
106
|
+
return write ? `${plural(tools, "tool")}, ${write} write` : `${plural(tools, "tool")}, all read`;
|
|
107
|
+
}
|
|
108
|
+
function callerSummary(caller) {
|
|
109
|
+
const name = caller.name;
|
|
110
|
+
if (!caller.endpoints.length) {
|
|
111
|
+
return caller.kind === "agent" ? `${name} is in no endpoint, so it reaches nothing.` : `${name} reaches nothing.`;
|
|
112
|
+
}
|
|
113
|
+
const served = servedTools(caller.endpoints);
|
|
114
|
+
// An agent connects only at a pool's agent URL, never the people's one.
|
|
115
|
+
const through = joinList(caller.endpoints.map((row) => (caller.kind === "agent" ? (row.agentPath ?? row.path) : row.path)));
|
|
116
|
+
const reach = served.connectors.size
|
|
117
|
+
? `${name} reaches ${plural(served.connectors.size, "connector")} through ${through}: ${toolsPhrase(served.tools, served.write)}.`
|
|
118
|
+
: `${name} uses ${through}, which serves no connectors.`;
|
|
119
|
+
if (caller.kind !== "agent" || tokenWorks(caller.agent))
|
|
120
|
+
return reach;
|
|
121
|
+
const state = caller.agent?.token.state;
|
|
122
|
+
return `${reach} ${state === "revoked" ? "Its token is revoked" : "It has no token"}, so it can't connect.`;
|
|
123
|
+
}
|
|
124
|
+
function callerDetail(caller, maintainers) {
|
|
125
|
+
if (caller.kind === "person")
|
|
126
|
+
return maintainers.has(caller.id) ? "maintainer" : "member";
|
|
127
|
+
const state = caller.agent?.token.state;
|
|
128
|
+
return state === "issued"
|
|
129
|
+
? "agent · token issued"
|
|
130
|
+
: state === "revoked"
|
|
131
|
+
? "agent · token revoked"
|
|
132
|
+
: "agent · no token";
|
|
133
|
+
}
|
|
134
|
+
function endpointSummary(row, callers) {
|
|
135
|
+
const listed = callers.filter((caller) => caller.endpoints.includes(row));
|
|
136
|
+
const people = listed.filter((caller) => caller.kind === "person").length;
|
|
137
|
+
const agents = listed.length - people;
|
|
138
|
+
const who = row.pool === null ? plural(people, "maintainer") : plural(people, "person", "people");
|
|
139
|
+
const users = agents ? `${who} and ${plural(agents, "agent")}` : who;
|
|
140
|
+
const served = servedTools([row]);
|
|
141
|
+
const mode = row.readOnly ? `${row.path} is read-only and serves ${users}` : `${row.path} serves ${users}`;
|
|
142
|
+
if (!served.connectors.size)
|
|
143
|
+
return `${mode}. It has no connectors.`;
|
|
144
|
+
return `${mode} with ${plural(served.connectors.size, "connector")}: ${toolsPhrase(served.tools, served.write)}.`;
|
|
145
|
+
}
|
|
146
|
+
const STATUS_SENTENCE = {
|
|
147
|
+
ok: "",
|
|
148
|
+
neutral: "",
|
|
149
|
+
warn: " It needs attention.",
|
|
150
|
+
danger: " It is failing.",
|
|
151
|
+
};
|
|
152
|
+
function connectorSummary(title, id, health, rows, callers) {
|
|
153
|
+
const on = rows.filter((row) => row.groups.some((group) => group.config.id === id));
|
|
154
|
+
const status = STATUS_SENTENCE[health.tone];
|
|
155
|
+
if (!on.length)
|
|
156
|
+
return `No endpoint serves ${title}, so nobody reaches it.${status}`;
|
|
157
|
+
const reaching = callers.filter((caller) => caller.endpoints.some((row) => on.includes(row)));
|
|
158
|
+
const people = reaching.filter((caller) => caller.kind === "person").length;
|
|
159
|
+
const agents = reaching.length - people;
|
|
160
|
+
const who = agents
|
|
161
|
+
? `${plural(people, "person", "people")} and ${plural(agents, "agent")}`
|
|
162
|
+
: plural(people, "person", "people");
|
|
163
|
+
const verb = reaching.length === 1 ? "reaches" : "reach";
|
|
164
|
+
return `${who} ${verb} ${title} through ${joinList(on.map((row) => row.path))}.${status}`;
|
|
165
|
+
}
|
|
166
|
+
function edgePath(from, to) {
|
|
167
|
+
const x1 = from.x + from.width;
|
|
168
|
+
const y1 = from.y + from.height / 2;
|
|
169
|
+
const x2 = to.x;
|
|
170
|
+
const y2 = to.y + to.height / 2;
|
|
171
|
+
const dx = (x2 - x1) / 2;
|
|
172
|
+
return `M${x1},${y1} C${x1 + dx},${y1} ${x2 - dx},${y2} ${x2},${y2}`;
|
|
173
|
+
}
|
|
174
|
+
/** Each column's nodes stacked from the top, the shorter columns centered on the tallest. */
|
|
175
|
+
function place(nodes) {
|
|
176
|
+
const columns = [0, 1, 2].map((column) => nodes.filter((node) => node.column === column));
|
|
177
|
+
const tallest = Math.max(1, ...columns.map((column) => column.length));
|
|
178
|
+
const body = tallest * (NODE_HEIGHT + NODE_GAP) - NODE_GAP;
|
|
179
|
+
columns.forEach((column, index) => {
|
|
180
|
+
const height = column.length * (NODE_HEIGHT + NODE_GAP) - NODE_GAP;
|
|
181
|
+
const top = HEAD + Math.max(0, (body - height) / 2);
|
|
182
|
+
column.forEach((node, position) => {
|
|
183
|
+
node.x = COLUMN_X[index];
|
|
184
|
+
node.width = COLUMN_WIDTH[index];
|
|
185
|
+
node.height = NODE_HEIGHT;
|
|
186
|
+
node.y = top + position * (NODE_HEIGHT + NODE_GAP);
|
|
187
|
+
if (node.kind === "connector")
|
|
188
|
+
node.toolsBox = { x: TOOLS_X, y: node.y, width: TOOLS_WIDTH, height: NODE_HEIGHT };
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
return HEAD + body + FOOT;
|
|
192
|
+
}
|
|
193
|
+
function node(fields, limit) {
|
|
194
|
+
return { hue: 0, ...fields, label: truncate(fields.name, limit), toolsBox: null, x: 0, y: 0, width: 0, height: 0 };
|
|
195
|
+
}
|
|
196
|
+
/** A connector's tile: two words' initials, a camel-case title's two capitals, or one letter. */
|
|
197
|
+
export function monogram(title) {
|
|
198
|
+
const words = title.split(/[\s._-]+/).filter(Boolean);
|
|
199
|
+
if (words.length > 1)
|
|
200
|
+
return (words[0][0] + words[1][0]).toUpperCase();
|
|
201
|
+
const capitals = title.match(/[A-Z]/g) ?? [];
|
|
202
|
+
if (capitals.length >= 2)
|
|
203
|
+
return capitals[0] + capitals[1];
|
|
204
|
+
return (words[0] ?? title).charAt(0).toUpperCase();
|
|
205
|
+
}
|
|
206
|
+
/** The whole map for a maintainer's contract; an empty map for anyone else. */
|
|
207
|
+
export function connectionMap(contract, options = {}) {
|
|
208
|
+
const rows = contract.role === "maintainer" ? endpointRows(contract) : [];
|
|
209
|
+
const callers = callersOf(contract, rows);
|
|
210
|
+
const maintainers = new Set((contract.root?.maintainers ?? []).flatMap((member) => member.kind === "human" ? [`person:${member.namespace}:${member.id}`] : []));
|
|
211
|
+
const connectors = (contract.role === "maintainer" ? (contract.config?.connectors ?? []) : []).flatMap((config) => {
|
|
212
|
+
const live = contract.live.connectors.find((c) => c.id === config.id);
|
|
213
|
+
return live ? [{ config, live, health: connectorVerdict(live, options.signals?.[config.id]) }] : [];
|
|
214
|
+
});
|
|
215
|
+
const health = new Map(connectors.map(({ config, health }) => [config.id, health]));
|
|
216
|
+
const nodes = [];
|
|
217
|
+
for (const caller of callers) {
|
|
218
|
+
const works = caller.kind === "person" || tokenWorks(caller.agent);
|
|
219
|
+
nodes.push(node({
|
|
220
|
+
id: caller.id,
|
|
221
|
+
kind: caller.kind,
|
|
222
|
+
column: 0,
|
|
223
|
+
name: caller.name,
|
|
224
|
+
detail: callerDetail(caller, maintainers),
|
|
225
|
+
href: caller.kind === "agent"
|
|
226
|
+
? options.tokensPage
|
|
227
|
+
? "/tokens"
|
|
228
|
+
: null
|
|
229
|
+
: caller.endpoints[0]
|
|
230
|
+
? endpointHref(caller.endpoints[0].path)
|
|
231
|
+
: null,
|
|
232
|
+
tone: works ? "neutral" : "warn",
|
|
233
|
+
readOnly: false,
|
|
234
|
+
tools: null,
|
|
235
|
+
initials: caller.kind === "person" ? initialsOf(caller.name) : "",
|
|
236
|
+
hue: caller.kind === "person" ? hueOf(caller.id.slice("person:".length)) : 0,
|
|
237
|
+
summary: callerSummary(caller),
|
|
238
|
+
}, LABEL_LIMIT[0]));
|
|
239
|
+
}
|
|
240
|
+
for (const row of rows) {
|
|
241
|
+
const listed = callers.filter((caller) => caller.endpoints.includes(row)).length;
|
|
242
|
+
nodes.push(node({
|
|
243
|
+
id: `endpoint:${row.path}`,
|
|
244
|
+
kind: "endpoint",
|
|
245
|
+
column: 1,
|
|
246
|
+
name: row.path,
|
|
247
|
+
detail: `${row.pool === null ? "maintainers" : plural(listed, "caller")} · ${plural(row.groups.length, "connector")}`,
|
|
248
|
+
href: endpointHref(row.path),
|
|
249
|
+
tone: "neutral",
|
|
250
|
+
readOnly: row.readOnly,
|
|
251
|
+
tools: null,
|
|
252
|
+
initials: "",
|
|
253
|
+
summary: endpointSummary(row, callers),
|
|
254
|
+
}, row.readOnly ? READ_ONLY_LABEL_LIMIT : LABEL_LIMIT[1]));
|
|
255
|
+
}
|
|
256
|
+
for (const { config, live, health: verdict } of connectors) {
|
|
257
|
+
const title = config.title ?? config.id;
|
|
258
|
+
const read = live.tools.filter((tool) => tool.classification === "read").length;
|
|
259
|
+
const scope = config.authScope === "personal" ? "personal" : "shared";
|
|
260
|
+
// A failed or unobserved catalog has no tools to count, which is not zero tools.
|
|
261
|
+
const known = live.tools.length > 0 || (live.status === "ok" && !live.problem);
|
|
262
|
+
nodes.push(node({
|
|
263
|
+
id: `connector:${config.id}`,
|
|
264
|
+
kind: "connector",
|
|
265
|
+
column: 2,
|
|
266
|
+
name: title,
|
|
267
|
+
detail: verdict.tone === "ok" ? `${scope} · ${plural(live.tools.length, "tool")}` : verdict.label,
|
|
268
|
+
href: connectorHref(config.id, verdict.tone === "ok"),
|
|
269
|
+
tone: verdict.tone,
|
|
270
|
+
readOnly: false,
|
|
271
|
+
tools: known ? { read, write: live.tools.length - read } : null,
|
|
272
|
+
initials: monogram(title),
|
|
273
|
+
summary: connectorSummary(title, config.id, verdict, rows, callers),
|
|
274
|
+
}, LABEL_LIMIT[2]));
|
|
275
|
+
}
|
|
276
|
+
const height = place(nodes);
|
|
277
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
278
|
+
const edges = [];
|
|
279
|
+
for (const caller of callers) {
|
|
280
|
+
for (const row of caller.endpoints) {
|
|
281
|
+
const from = byId.get(caller.id);
|
|
282
|
+
const to = byId.get(`endpoint:${row.path}`);
|
|
283
|
+
edges.push({
|
|
284
|
+
id: `${from.id}>${to.id}`,
|
|
285
|
+
from: from.id,
|
|
286
|
+
to: to.id,
|
|
287
|
+
kind: "access",
|
|
288
|
+
tone: "neutral",
|
|
289
|
+
readOnly: false,
|
|
290
|
+
noToken: caller.kind === "agent" && !tokenWorks(caller.agent),
|
|
291
|
+
served: null,
|
|
292
|
+
path: edgePath(from, to),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
for (const row of rows) {
|
|
297
|
+
for (const group of row.groups) {
|
|
298
|
+
const from = byId.get(`endpoint:${row.path}`);
|
|
299
|
+
const to = byId.get(`connector:${group.config.id}`);
|
|
300
|
+
if (!to)
|
|
301
|
+
continue;
|
|
302
|
+
edges.push({
|
|
303
|
+
id: `${from.id}>${to.id}`,
|
|
304
|
+
from: from.id,
|
|
305
|
+
to: to.id,
|
|
306
|
+
kind: "grant",
|
|
307
|
+
tone: health.get(group.config.id)?.tone ?? "neutral",
|
|
308
|
+
readOnly: row.readOnly,
|
|
309
|
+
noToken: false,
|
|
310
|
+
served: {
|
|
311
|
+
tools: group.tools.length,
|
|
312
|
+
write: group.tools.filter((tool) => tool.classification === "write").length,
|
|
313
|
+
},
|
|
314
|
+
path: edgePath(from, to),
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
nodes,
|
|
320
|
+
edges,
|
|
321
|
+
width: MAP_WIDTH,
|
|
322
|
+
height,
|
|
323
|
+
columns: [
|
|
324
|
+
{ label: "Callers", count: callers.length, x: COLUMN_X[0] },
|
|
325
|
+
{ label: "Endpoints", count: rows.length, x: COLUMN_X[1] },
|
|
326
|
+
{ label: "Connectors", count: connectors.length, x: COLUMN_X[2] },
|
|
327
|
+
{ label: "Tools", count: connectors.reduce((n, { live }) => n + live.tools.length, 0), x: TOOLS_X },
|
|
328
|
+
],
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* What hovering a node lights up: every node and link on a path through it.
|
|
333
|
+
* Links are walked forward from the node and backward to it, never both ways
|
|
334
|
+
* from a neighbor, so a caller lights only what it reaches and a connector
|
|
335
|
+
* lights only who reaches it.
|
|
336
|
+
*/
|
|
337
|
+
export function mapHighlight(map, id) {
|
|
338
|
+
const nodes = new Set([id]);
|
|
339
|
+
const edges = new Set();
|
|
340
|
+
const walk = (forward) => {
|
|
341
|
+
const queue = [id];
|
|
342
|
+
const seen = new Set([id]);
|
|
343
|
+
while (queue.length) {
|
|
344
|
+
const current = queue.shift();
|
|
345
|
+
for (const edge of map.edges) {
|
|
346
|
+
if ((forward ? edge.from : edge.to) !== current)
|
|
347
|
+
continue;
|
|
348
|
+
const target = forward ? edge.to : edge.from;
|
|
349
|
+
edges.add(edge.id);
|
|
350
|
+
nodes.add(target);
|
|
351
|
+
if (!seen.has(target)) {
|
|
352
|
+
seen.add(target);
|
|
353
|
+
queue.push(target);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
walk(true);
|
|
359
|
+
walk(false);
|
|
360
|
+
return { nodes, edges };
|
|
361
|
+
}
|
|
362
|
+
export function mapList(map) {
|
|
363
|
+
const byId = new Map(map.nodes.map((n) => [n.id, n]));
|
|
364
|
+
return map.nodes
|
|
365
|
+
.filter((n) => n.kind === "endpoint")
|
|
366
|
+
.map((endpoint) => ({
|
|
367
|
+
endpoint,
|
|
368
|
+
callers: map.edges
|
|
369
|
+
.filter((edge) => edge.to === endpoint.id)
|
|
370
|
+
.map((edge) => ({ node: byId.get(edge.from), noToken: edge.noToken })),
|
|
371
|
+
connectors: map.edges
|
|
372
|
+
.filter((edge) => edge.from === endpoint.id)
|
|
373
|
+
.map((edge) => ({ node: byId.get(edge.to), served: edge.served ?? { tools: 0, write: 0 } })),
|
|
374
|
+
}));
|
|
375
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** How many chips an endpoint row shows before "+N more". */
|
|
2
2
|
const ACCESS_CHIP_LIMIT = 4;
|
|
3
|
-
|
|
3
|
+
/** Up to two letters for a person's avatar, from their label or raw id. */
|
|
4
|
+
export function initialsOf(text) {
|
|
4
5
|
const words = text
|
|
5
6
|
.replace(/^user_/, "")
|
|
6
7
|
.split(/[\s._@-]+/)
|
|
@@ -8,7 +9,8 @@ function initialsOf(text) {
|
|
|
8
9
|
const letters = words.length > 1 ? words[0][0] + words[1][0] : (words[0] ?? text).slice(0, 2);
|
|
9
10
|
return letters.toUpperCase();
|
|
10
11
|
}
|
|
11
|
-
|
|
12
|
+
/** A stable tint bucket for a member key, shared by chips and the Overview map. */
|
|
13
|
+
export function hueOf(key) {
|
|
12
14
|
let hash = 0;
|
|
13
15
|
for (const char of key)
|
|
14
16
|
hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
|
|
@@ -18,7 +20,7 @@ function hueOf(key) {
|
|
|
18
20
|
function accessChip(member) {
|
|
19
21
|
if (member.kind === "agent") {
|
|
20
22
|
const key = `agent:${member.name}`;
|
|
21
|
-
return { key, kind: "agent", text:
|
|
23
|
+
return { key, kind: "agent", text: member.name, initials: "", hue: hueOf(key) };
|
|
22
24
|
}
|
|
23
25
|
const key = `${member.namespace}:${member.id}`;
|
|
24
26
|
const text = member.label ?? member.id;
|
|
@@ -71,6 +73,18 @@ function endpointGroups(contract, grants, readOnly, pool) {
|
|
|
71
73
|
});
|
|
72
74
|
}
|
|
73
75
|
const toolCount = (groups) => groups.reduce((n, group) => n + group.tools.length, 0);
|
|
76
|
+
/** How many of these tools read and how many write; an unclassified tool counts as neither. */
|
|
77
|
+
export function toolSplit(tools) {
|
|
78
|
+
let read = 0;
|
|
79
|
+
let write = 0;
|
|
80
|
+
for (const tool of tools) {
|
|
81
|
+
if (tool.classification === "read")
|
|
82
|
+
read++;
|
|
83
|
+
else if (tool.classification === "write")
|
|
84
|
+
write++;
|
|
85
|
+
}
|
|
86
|
+
return { read, write };
|
|
87
|
+
}
|
|
74
88
|
/** One row for plain /mcp, then one per pool, in contract order. Maintainer view only. */
|
|
75
89
|
export function endpointRows(contract) {
|
|
76
90
|
if (contract.role !== "maintainer")
|
|
@@ -150,24 +164,46 @@ export function matrixCell(contract, connectorId, column) {
|
|
|
150
164
|
const cell = contract.matrix.find((c) => c.connectorId === connectorId && c.pool === column.pool);
|
|
151
165
|
if (!cell)
|
|
152
166
|
return { kind: "none" };
|
|
153
|
-
const
|
|
167
|
+
const overlay = contract.live.connectors.find((c) => c.id === connectorId);
|
|
168
|
+
const live = overlay?.tools ?? [];
|
|
154
169
|
const tools = cell.tools === "all"
|
|
155
170
|
? live.map(({ name, classification }) => ({ name, classification }))
|
|
156
171
|
: cell.tools.map((name) => ({ name, classification: live.find((t) => t.name === name)?.classification ?? null }));
|
|
157
|
-
|
|
172
|
+
const known = overlay && (live.length > 0 || (overlay.status === "ok" && !overlay.problem));
|
|
173
|
+
const granted = cell.tools === "all" ? live : live.filter((tool) => cell.tools.includes(tool.name));
|
|
174
|
+
return {
|
|
175
|
+
kind: cell.tools === "all" ? "all" : "some",
|
|
176
|
+
tools,
|
|
177
|
+
runsAs: cell.runsAs,
|
|
178
|
+
coverage: known ? { granted: granted.length, total: live.length } : null,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/** The live grant fraction, or null when no complete catalog is available. */
|
|
182
|
+
export function matrixCellShare(cell) {
|
|
183
|
+
if (cell.kind === "none")
|
|
184
|
+
return 0;
|
|
185
|
+
if (!cell.coverage)
|
|
186
|
+
return null;
|
|
187
|
+
return cell.coverage.total ? cell.coverage.granted / cell.coverage.total : 0;
|
|
158
188
|
}
|
|
159
189
|
/** The cell's visible text: "all", a count, or a dash. */
|
|
160
190
|
export function matrixCellText(cell) {
|
|
161
191
|
if (cell.kind === "none")
|
|
162
192
|
return "—";
|
|
163
|
-
|
|
193
|
+
if (!cell.coverage)
|
|
194
|
+
return "?";
|
|
195
|
+
return cell.kind === "all" ? "all" : String(cell.coverage.granted);
|
|
164
196
|
}
|
|
165
|
-
/** The cell's accessible name, e.g. "GitHub on /support:
|
|
197
|
+
/** The cell's accessible name, e.g. "GitHub on /mcp/support: 1 of 2 live tools". */
|
|
166
198
|
export function matrixCellLabel(connector, column, cell) {
|
|
167
199
|
const where = `${connector} on ${column.path}`;
|
|
168
200
|
if (cell.kind === "none")
|
|
169
201
|
return `${where}: no tools`;
|
|
170
|
-
const what = cell.
|
|
202
|
+
const what = !cell.coverage
|
|
203
|
+
? "unknown live tool coverage"
|
|
204
|
+
: cell.kind === "all"
|
|
205
|
+
? `all ${cell.coverage.total} tools`
|
|
206
|
+
: `${cell.coverage.granted} of ${cell.coverage.total} live tools`;
|
|
171
207
|
return `${where}: ${what}${column.readOnly ? ", read-only" : ""}`;
|
|
172
208
|
}
|
|
173
209
|
/** The connector's subject for the matrix's Runs as column: the /mcp cell, else its first pool's. */
|
|
@@ -183,10 +219,3 @@ export function connectorRunsAs(contract, connectorId) {
|
|
|
183
219
|
}
|
|
184
220
|
return null;
|
|
185
221
|
}
|
|
186
|
-
/** Who may use plain /mcp, for Settings: each maintainer by label, else by raw id. */
|
|
187
|
-
export function rootAccessSentence(root) {
|
|
188
|
-
const names = root.maintainers.map((member) => (member.kind === "human" ? (member.label ?? member.id) : member.name));
|
|
189
|
-
return names.length
|
|
190
|
-
? `Only maintainers can use ${root.path}: ${names.join(", ")}.`
|
|
191
|
-
: `Only maintainers can use ${root.path}, and none are listed.`;
|
|
192
|
-
}
|
|
@@ -11,7 +11,7 @@ const CATALOGUE = {
|
|
|
11
11
|
oauth_required: {
|
|
12
12
|
problem: "A downstream OAuth connector needs authorization: no grant is stored, or the stored grant expired or was revoked and could not be refreshed.",
|
|
13
13
|
steps: [
|
|
14
|
-
"Reauthorize from the
|
|
14
|
+
"Reauthorize from the connector's Connection tab (Connect or Reconnect) or with authorize_connector. A lapsed grant needs no code change.",
|
|
15
15
|
"If it needs reauthorizing again soon after, check that the OAuth client requests offline access or a refresh token, and that the storage holding OAuth tokens persists across restarts and is shared by every instance.",
|
|
16
16
|
"If authorization cannot start at all, check the connector's OAuth client configuration and the deployment's publicUrl, which forms the /oauth/callback/<connector id> redirect URI.",
|
|
17
17
|
],
|