@capacms/mcp 0.2.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.
@@ -0,0 +1,176 @@
1
+ /**
2
+ * rest-tools.mjs — reading entries over REST, where GraphQL does not.
3
+ *
4
+ * `CAPA_API_GRAPHQL=off` takes `/api/graphql` away and leaves `/api/entries`
5
+ * serving, so a key that reads content still can. The GraphQL tools are not
6
+ * registered there (registry.mjs), and without this tool an assistant would
7
+ * lose every read for as long as GraphQL is off. It is registered then, and
8
+ * where GraphQL leaves some of the key's models out (N1: their type names
9
+ * collide), for those. Elsewhere capa_graphql_build writes the same REST read
10
+ * and checks it, and a second way to read would only cost context.
11
+ *
12
+ * It takes REST's parameters as docs/api/entries.md writes them and passes
13
+ * them through, so the API's own grammar, limits and refusals apply. The
14
+ * answer is bounded like every other (lib/bound.mjs), and a cut page stays
15
+ * pageable: its `next` is null, with how to read on, since the uncut page's
16
+ * cursor would skip every entry cut.
17
+ */
18
+ import { reads } from "./annotations.mjs";
19
+ import { answerSchema, BOUNDED, MAX_CHARS, READ_WITH_KEYS, readWith } from "./answers.mjs";
20
+ import { boundAnswer, DEFAULT_MAX_CHARS } from "./bound.mjs";
21
+ import { apiNextGet, CapaApiError } from "./client.mjs";
22
+ import { didYouMean } from "./suggest.mjs";
23
+
24
+ /** What a cut read tells the agent. */
25
+ const READ_HINT = "Pass select for fewer fields, or a smaller limit.";
26
+ /** How to use the tool, whichever reason it is registered for. */
27
+ const USAGE =
28
+ "With model: a page of entries, or one entry by id. select, where, sort, limit, after, before and " +
29
+ 'count take REST\'s grammar, e.g. select "title,author(name)", where { "views": { "gte": 10 } }, sort ["-views"]; ' +
30
+ "follow page.next with after. A large answer is cut from the end and says where; a cut page's next is null, with " +
31
+ "how to read on. capa_explain_error explains a refusal.";
32
+ /** The parameters that read a list, which a read of one entry by id does not take. */
33
+ const LIST_PARAMETERS = ["where", "sort", "limit", "after", "before", "count"];
34
+
35
+ /** Encoded as the API prints a REST request: commas, colons and a system key's `$` stay readable. */
36
+ const encode = (value) => encodeURIComponent(value).replace(/%2C/g, ",").replace(/%3A/g, ":").replace(/%24/g, "$");
37
+
38
+ /** The REST request `args` make: its path, its parameters in the API's order, and the two as one URL. */
39
+ export function restRead(args) {
40
+ const path = `/api/entries/${encodeURIComponent(args.model)}${args.id === undefined ? "" : `/${encodeURIComponent(args.id)}`}`;
41
+ const params = [];
42
+ if (args.select !== undefined) params.push(["select", args.select]);
43
+ if (args.where !== undefined) params.push(["where", JSON.stringify(args.where)]);
44
+ if (args.sort !== undefined) params.push(["sort", Array.isArray(args.sort) ? args.sort.join(",") : args.sort]);
45
+ if (args.limit !== undefined) params.push(["limit", String(args.limit)]);
46
+ if (args.after !== undefined) params.push(["after", args.after]);
47
+ if (args.before !== undefined) params.push(["before", args.before]);
48
+ if (args.count) params.push(["count", "true"]);
49
+ const url = params.length ? `${path}?${params.map(([key, value]) => `${key}=${encode(value)}`).join("&")}` : path;
50
+ return { path, params, url };
51
+ }
52
+
53
+ /** A REST page's cursor keys, read forward (`next`) or back (`prev`), as bound.mjs repairs a connection's. */
54
+ const pageKeys = (backward) =>
55
+ backward
56
+ ? { hasNextPage: [], hasPreviousPage: ["hasPrev"], startCursor: ["prev"], endCursor: [] }
57
+ : { hasNextPage: ["hasNext"], hasPreviousPage: [], startCursor: [], endCursor: ["next"] };
58
+
59
+ /**
60
+ * The pages in a REST answer, keyed by where each sits, for boundAnswer: the
61
+ * entry list itself (`data`, with `page`), and every expanded relation list
62
+ * (`{ items, pageInfo }`) inside it. A cut one gets its `next` set to null,
63
+ * with how to read on in REST's words.
64
+ */
65
+ export function restPages(answer, { backward = false } = {}) {
66
+ const pages = {};
67
+ if (Array.isArray(answer.data)) {
68
+ pages[""] = {
69
+ nodes: ["data"],
70
+ edges: [],
71
+ pageInfo: [{ key: "page", fields: pageKeys(backward) }],
72
+ backward,
73
+ resume: (kept) =>
74
+ backward
75
+ ? `page.prev is null because entries were cut; the uncut page's would skip them. Read again with limit: ${kept} and the same before for a prev before the first entry shown.`
76
+ : `page.next is null because entries were cut; the uncut page's would skip them. Read again with limit: ${kept} for a next after the last entry shown.`,
77
+ };
78
+ }
79
+ const walk = (value, shape) => {
80
+ if (Array.isArray(value)) {
81
+ for (const item of value) walk(item, shape);
82
+ return;
83
+ }
84
+ if (!value || typeof value !== "object") return;
85
+ if (Array.isArray(value.items) && value.pageInfo && typeof value.pageInfo === "object") {
86
+ pages[shape] = {
87
+ nodes: ["items"],
88
+ edges: [],
89
+ pageInfo: [{ key: "pageInfo", fields: pageKeys(false) }],
90
+ backward: false,
91
+ resume: (kept) =>
92
+ `pageInfo.next is null because items were cut; the uncut list's would skip them. Read the entry again with limit:${kept} on this relation for a next after the last item shown.`,
93
+ };
94
+ }
95
+ for (const [key, inner] of Object.entries(value)) walk(inner, shape ? `${shape}.${key}` : key);
96
+ };
97
+ walk(answer.data, "data");
98
+ return pages;
99
+ }
100
+
101
+ /** The models this key reads, from `/api/me`, or null where the deployment does not list them (`CAPA_KEY_SCOPES=off`). */
102
+ async function readableModels(config) {
103
+ const { data } = await apiNextGet(config, "/api/me");
104
+ if (!Array.isArray(data?.models)) return { environment: data?.environment, models: null };
105
+ const models = data.models.filter((m) => m.can?.includes("read")).map((m) => m.namespace);
106
+ return { environment: data.environment, models };
107
+ }
108
+
109
+ export const REST_TOOLS = [
110
+ {
111
+ name: "capa_read_entries",
112
+ ...reads("Read entries over REST"),
113
+ ...BOUNDED,
114
+ surface: "api",
115
+ scopePrefix: "instance:read",
116
+ // Registered where the deployment serves no GraphQL, or where GraphQL leaves models out (registry.mjs).
117
+ whenOff: "graphql",
118
+ description: `Read entries over REST (GET /api/entries/<model>): this deployment serves no GraphQL. Without model: the models this key reads. ${USAGE}`,
119
+ /** The description where GraphQL is served and leaves models out, which this tool is then registered to read. */
120
+ besideGraphQL:
121
+ "Read entries over REST (GET /api/entries/<model>) of the models GraphQL leaves out, which capa_graphql_schema " +
122
+ `lists as readable over REST only; read every other model with capa_graphql_build. ${USAGE}`,
123
+ inputSchema: {
124
+ type: "object",
125
+ properties: {
126
+ model: { type: "string", description: 'Namespace, e.g. "articles".' },
127
+ id: { type: "string", description: "Entry UUID: reads one entry." },
128
+ select: { type: "string" },
129
+ where: { type: "object" },
130
+ sort: { type: ["string", "array"], items: { type: "string" } },
131
+ limit: { type: "integer", minimum: 1, maximum: 200 },
132
+ after: { type: "string" },
133
+ before: { type: "string" },
134
+ count: { type: "boolean" },
135
+ maxChars: MAX_CHARS,
136
+ },
137
+ additionalProperties: false,
138
+ },
139
+ outputSchema: answerSchema({
140
+ models: { type: "array", items: { type: "string" } },
141
+ data: { type: ["array", "object"] },
142
+ page: { type: "object", description: "limit, hasNext, next, hasPrev, prev, and total with count." },
143
+ rest: { type: "string", description: "The request read." },
144
+ ...READ_WITH_KEYS,
145
+ }),
146
+ handler: async (config, args) => {
147
+ if (args.model === undefined) {
148
+ const { environment, models } = await readableModels(config);
149
+ if (!models) {
150
+ return { error: "This deployment does not list the models a key reads (CAPA_KEY_SCOPES is off). Pass model with a namespace from the Capa admin." };
151
+ }
152
+ return { models, ...readWith(environment), next: "Pass model to read its entries." };
153
+ }
154
+ if (args.id !== undefined) {
155
+ const listOnly = LIST_PARAMETERS.filter((key) => args[key] !== undefined);
156
+ if (listOnly.length) return { error: `id reads one entry, so ${listOnly.join(", ")} do not apply: leave id out to list entries.` };
157
+ }
158
+ const read = restRead(args);
159
+ let body;
160
+ try {
161
+ body = await apiNextGet(config, read.path, Object.fromEntries(read.params));
162
+ } catch (error) {
163
+ if (!(error instanceof CapaApiError) || error.code !== "model_not_found") throw error;
164
+ // A wrong namespace comes back with the nearest, from the models this key reads.
165
+ const { models } = await readableModels(config).catch(() => ({ models: null }));
166
+ if (!models) throw error;
167
+ return { error: `This key reads no model ${args.model}.`, didYouMean: didYouMean(args.model, models), available: models };
168
+ }
169
+ const answer = { data: body.data ?? null };
170
+ if (body.page) answer.page = body.page;
171
+ Object.assign(answer, readWith(body.meta?.environment), { rest: read.url });
172
+ const connections = restPages(answer, { backward: args.before !== undefined });
173
+ return boundAnswer(answer, args.maxChars ?? DEFAULT_MAX_CHARS, { hint: READ_HINT, connections });
174
+ },
175
+ },
176
+ ];
package/lib/server.mjs ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * server.mjs — MCP stdio protocol, implemented directly.
3
+ *
4
+ * Newline-delimited JSON-RPC 2.0: one message per line on stdin/stdout, and
5
+ * LOGS TO STDERR ONLY. A stray console.log on stdout corrupts the stream and
6
+ * the client sees a parse error rather than anything useful, which is the
7
+ * single easiest way to break one of these.
8
+ *
9
+ * `dispatch` is pure over ({ config, tools }) so the protocol is unit-testable
10
+ * without wiring real stdio. It returns a response object for requests (those
11
+ * with an `id`) and `null` for notifications (no `id`) — the caller writes
12
+ * non-null responses back.
13
+ *
14
+ * `ctx.tools` IS THE REGISTERED SET, not every tool that exists. It is computed
15
+ * once at startup by `selectTools` in registry.mjs, because which tools work
16
+ * depends on the key's family and scopes. There is deliberately no fallback to
17
+ * the full list: a caller that forgot to select would otherwise advertise the
18
+ * legacy tools to a `cap_` key, which is the exact thing the registry exists to
19
+ * stop, and a loud failure beats a quiet over-advertisement.
20
+ */
21
+ import { checkArguments } from "./arguments.mjs";
22
+ import { boundAnswer, DEFAULT_MAX_CHARS } from "./bound.mjs";
23
+ import { CapaApiError } from "./client.mjs";
24
+ import { availablePrompts, renderPrompt } from "./prompts.mjs";
25
+ import { availableTemplates, listResources, readResource, ResourceNotFound } from "./resources.mjs";
26
+ import { instructionsFor } from "./instructions.mjs";
27
+
28
+ const PROTOCOL_VERSION = "2025-06-18";
29
+ const SUPPORTED = new Set(["2025-06-18", "2025-03-26", "2024-11-05"]);
30
+
31
+ export const SERVER_INFO = { name: "capa-mcp", version: "0.2.0" };
32
+
33
+ const ok = (id, result) => ({ jsonrpc: "2.0", id, result });
34
+ const fail = (id, code, message) => ({ jsonrpc: "2.0", id, error: { code, message } });
35
+
36
+ /**
37
+ * `args` with each of the tool's `aliases` read as the name it stands for:
38
+ * the legacy tools call a model `namespace` (or `modelId`), and an agent that
39
+ * learnt `model` from the GraphQL tools is understood. An alias passed beside
40
+ * its name is left as it is, and refused by the check as the unknown argument
41
+ * it then is.
42
+ */
43
+ function withAliases(tool, args) {
44
+ if (!tool.aliases || !args || typeof args !== "object" || Array.isArray(args)) return args;
45
+ const out = { ...args };
46
+ for (const [alias, name] of Object.entries(tool.aliases)) {
47
+ if (!(alias in out) || name in out) continue;
48
+ out[name] = out[alias];
49
+ delete out[alias];
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /**
55
+ * `config` as a handler gets it: with the call's `signal`, which every
56
+ * request the call makes honours (client.mjs), so a call the client cancels
57
+ * stops reaching Capa.
58
+ */
59
+ const configFor = (ctx, signal) => (signal ? { ...ctx.config, signal } : ctx.config);
60
+
61
+ async function callTool(ctx, params, signal) {
62
+ const name = params?.name;
63
+ const tools = ctx.tools;
64
+ const tool = name ? tools.find((t) => t.name === name) : undefined;
65
+ if (!tool) {
66
+ // Unknown tool is an in-band tool error per MCP, not a protocol error —
67
+ // the model should see it and pick a different tool, not get a JSON-RPC
68
+ // failure it cannot reason about.
69
+ //
70
+ // The alternatives named are the REGISTERED ones. Listing a tool this key
71
+ // cannot reach would answer "that one does not exist" with a suggestion
72
+ // that is equally unusable.
73
+ return {
74
+ content: [
75
+ {
76
+ type: "text",
77
+ text:
78
+ `Unknown tool: ${String(name)}\n` +
79
+ `Available: ${tools.map((t) => t.name).join(", ")}`,
80
+ },
81
+ ],
82
+ isError: true,
83
+ };
84
+ }
85
+ // Checked against the schema the tool advertised, so a misspelled or
86
+ // mistyped argument is refused by name instead of silently ignored.
87
+ const args = withAliases(tool, params.arguments);
88
+ const problem = checkArguments(tool.inputSchema, args);
89
+ if (problem) {
90
+ return { content: [{ type: "text", text: `Invalid arguments for ${name}: ${problem}` }], isError: true };
91
+ }
92
+ try {
93
+ const answer = await tool.handler(configFor(ctx, signal), args ?? {});
94
+ // Every answer fits its budget and says how to ask for less (lib/bound.mjs),
95
+ // except a document an agent edits and writes back: a cut copy written back
96
+ // would lose what was cut. A tool that bounds its own answer (`bounded`) is
97
+ // not cut again: only it knows which parts are never cut (a built query's
98
+ // document and REST twin) and how a connection pages on once cut.
99
+ const data = tool.whole || tool.bounded ? answer : boundAnswer(answer, args?.maxChars ?? DEFAULT_MAX_CHARS, { hint: tool.cutHint });
100
+ // Compact: indentation is a third of the characters and tells a model nothing.
101
+ const content = [{ type: "text", text: JSON.stringify(data) }];
102
+ // A tool with an output schema also answers the object itself, for a
103
+ // client that reads results as data; the text stays for one that does not.
104
+ return tool.outputSchema ? { content, structuredContent: data } : { content };
105
+ } catch (error) {
106
+ // Same reasoning: an API failure is reported to the MODEL so it can adapt
107
+ // (retry narrower, pick another tool), rather than surfaced as a transport
108
+ // error that ends the turn.
109
+ //
110
+ // The API's OWN hint wins when there is one. `/api/` answers the fix in
111
+ // this deployment's real values ("this key needs instance:read"), and
112
+ // replacing that with the generic sentence below would throw away the only
113
+ // part of the error a model can act on without guessing.
114
+ const text =
115
+ error instanceof CapaApiError
116
+ ? `${error.message}\n${
117
+ error.hint ??
118
+ "(the Capa API rejected this call; a 401 means the key or tenant is wrong, a 404 means the model or id does not exist)"
119
+ }`
120
+ : String(error?.message ?? error);
121
+ return { content: [{ type: "text", text }], isError: true };
122
+ }
123
+ }
124
+
125
+ /**
126
+ * One message's answer. `signal` is the call's own, aborted when the client
127
+ * cancels it (session.mjs); a message that never reaches Capa has none.
128
+ */
129
+ export async function dispatch(ctx, msg, { signal } = {}) {
130
+ const { id, method, params } = msg ?? {};
131
+ const isNotification = id === undefined || id === null;
132
+
133
+ switch (method) {
134
+ case "initialize": {
135
+ const asked = params?.protocolVersion;
136
+ return ok(id, {
137
+ protocolVersion: SUPPORTED.has(asked) ? asked : PROTOCOL_VERSION,
138
+ capabilities: { tools: {}, resources: {}, prompts: {} },
139
+ serverInfo: SERVER_INFO,
140
+ instructions: instructionsFor(ctx.tools),
141
+ });
142
+ }
143
+ case "notifications/initialized":
144
+ return null;
145
+ case "ping":
146
+ return ok(id, {});
147
+ case "tools/list":
148
+ return ok(id, {
149
+ tools: ctx.tools.map(({ name, title, description, inputSchema, outputSchema, annotations }) => ({
150
+ name,
151
+ title,
152
+ description,
153
+ inputSchema,
154
+ ...(outputSchema ? { outputSchema } : {}),
155
+ annotations,
156
+ })),
157
+ });
158
+ case "tools/call":
159
+ return ok(id, await callTool(ctx, params, signal));
160
+ case "resources/list":
161
+ return ok(id, { resources: await listResources(configFor(ctx, signal), ctx.tools) });
162
+ case "resources/templates/list":
163
+ return ok(id, {
164
+ resourceTemplates: availableTemplates(ctx.tools).map(({ uriTemplate, name, description, mimeType }) => ({
165
+ uriTemplate,
166
+ name,
167
+ description,
168
+ mimeType,
169
+ })),
170
+ });
171
+ case "resources/read": {
172
+ try {
173
+ const { resource, text } = await readResource(configFor(ctx, signal), ctx.tools, params?.uri);
174
+ return ok(id, { contents: [{ uri: resource.uri, mimeType: resource.mimeType, text }] });
175
+ } catch (error) {
176
+ // -32002 is MCP's "resource not found".
177
+ if (error instanceof ResourceNotFound) return fail(id, -32002, error.message);
178
+ return fail(id, -32603, `Could not read ${String(params?.uri)}: ${String(error?.message ?? error)}`);
179
+ }
180
+ }
181
+ case "prompts/list":
182
+ return ok(id, {
183
+ prompts: availablePrompts(ctx.tools).map(({ name, description, arguments: args }) => ({ name, description, arguments: args })),
184
+ });
185
+ case "prompts/get": {
186
+ const rendered = renderPrompt(params?.name, params?.arguments ?? {}, ctx.tools);
187
+ // -32602: invalid params, which an unknown name or a missing argument is.
188
+ return rendered.error ? fail(id, -32602, rendered.error) : ok(id, rendered);
189
+ }
190
+ default:
191
+ if (isNotification) return null;
192
+ return fail(id, -32601, `Method not found: ${String(method)}`);
193
+ }
194
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * session.mjs — one client's stream of messages: which wait their turn, which
3
+ * answer at once, and which the client called off.
4
+ *
5
+ * A message that reaches Capa (a tool that calls the API, a resource) waits
6
+ * for the one before it: those share the schema cache, and an agent's calls
7
+ * come one after another anyway. Everything else answers at once, beside a
8
+ * call in flight: `ping`, `initialize`, the lists and prompts, and a tool that
9
+ * answers offline (`capa_explain_error`). A client pings to see whether the
10
+ * server is alive, and one call to a slow host must not make it look dead.
11
+ *
12
+ * `notifications/cancelled` aborts the call it names, running or still
13
+ * waiting, and that call is never answered, as MCP requires: the client has
14
+ * stopped listening for it.
15
+ */
16
+ import { dispatch } from "./server.mjs";
17
+
18
+ /** Whether answering `msg` reaches Capa: a registered tool that is not offline, or a resource. */
19
+ function reachesCapa(ctx, msg) {
20
+ if (msg.method === "resources/list" || msg.method === "resources/read") return true;
21
+ if (msg.method !== "tools/call") return false;
22
+ const tool = ctx.tools.find((t) => t.name === msg.params?.name);
23
+ return Boolean(tool) && tool.surface !== "local";
24
+ }
25
+
26
+ /**
27
+ * `receive(line)` takes one line of the stream; `write(message)` sends an
28
+ * answer; `log(text)` reports to the person running the server (stderr).
29
+ * `drain()` settles once every message received so far has been answered or
30
+ * called off.
31
+ */
32
+ export function createSession(ctx, { write, log }) {
33
+ let turn = Promise.resolve();
34
+ const unsettled = new Set();
35
+ /** The call each request id names, while it waits or runs. */
36
+ const calls = new Map();
37
+
38
+ const track = (promise) => {
39
+ unsettled.add(promise);
40
+ promise.finally(() => unsettled.delete(promise));
41
+ };
42
+
43
+ const answer = async (msg, signal) => {
44
+ try {
45
+ const res = await dispatch(ctx, msg, { signal });
46
+ if (res && !signal?.aborted) write(res);
47
+ } catch (error) {
48
+ // A handler bug must not end the stream: the client would wait forever.
49
+ log(`capa-mcp: dispatch threw: ${String(error?.stack ?? error)}`);
50
+ if (!signal?.aborted && msg.id !== undefined && msg.id !== null) {
51
+ write({ jsonrpc: "2.0", id: msg.id, error: { code: -32603, message: "Internal error" } });
52
+ }
53
+ }
54
+ };
55
+
56
+ const receive = (line) => {
57
+ const trimmed = line.trim();
58
+ if (!trimmed) return;
59
+ let msg;
60
+ try {
61
+ msg = JSON.parse(trimmed);
62
+ } catch {
63
+ log(`capa-mcp: unparseable line ignored: ${trimmed.slice(0, 120)}`);
64
+ return;
65
+ }
66
+ if (msg?.method === "notifications/cancelled") {
67
+ calls.get(msg.params?.requestId)?.abort();
68
+ return;
69
+ }
70
+ if (!reachesCapa(ctx, msg ?? {})) {
71
+ track(answer(msg));
72
+ return;
73
+ }
74
+ const call = new AbortController();
75
+ const id = msg.id;
76
+ if (id !== undefined && id !== null) calls.set(id, call);
77
+ const run = turn.then(async () => {
78
+ if (!call.signal.aborted) await answer(msg, call.signal);
79
+ if (calls.get(id) === call) calls.delete(id);
80
+ });
81
+ turn = run;
82
+ track(run);
83
+ };
84
+
85
+ const drain = async () => {
86
+ while (unsettled.size) await Promise.allSettled([...unsettled]);
87
+ };
88
+
89
+ return { receive, drain };
90
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * suggest.mjs — "did you mean": the names nearest a misspelled one.
3
+ *
4
+ * Shared by the query builder (a field, model or sort value) and the tool
5
+ * argument check (an argument name), so a typo gets the same answer wherever
6
+ * an agent makes it.
7
+ */
8
+
9
+ function levenshtein(a, b) {
10
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
11
+ for (let i = 1; i <= a.length; i++) {
12
+ let previous = row[0];
13
+ row[0] = i;
14
+ for (let j = 1; j <= b.length; j++) {
15
+ const current = row[j];
16
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
17
+ previous = current;
18
+ }
19
+ }
20
+ return row[b.length];
21
+ }
22
+
23
+ /** Names within two edits of `wanted`, nearest first then by name, at most three. */
24
+ export function didYouMean(wanted, candidates) {
25
+ const needle = String(wanted).toLowerCase();
26
+ return [...new Set(candidates)]
27
+ .map((name) => ({ name, distance: levenshtein(needle, name.toLowerCase()) }))
28
+ .filter((c) => c.distance <= 2)
29
+ .sort((a, b) => a.distance - b.distance || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
30
+ .slice(0, 3)
31
+ .map((c) => c.name);
32
+ }