diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4f7713ec..b78285b7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,6 +32,24 @@ refusing to boot over.
The example package ships four: `/find-a-document`, `/whats-changed`, `/who-owns-this` and
`/check-a-claim`.
+### The tools a skill needs can be picked where the skill is written
+
+A package's skills arrive with their tools declared. A skill somebody writes here could not: the
+`tools` field existed on the save endpoint and on no screen, so a skill written in the product declared
+nothing, and the only way to change that was to call the API by hand. Writing or editing a skill now
+lists the tools of every connected server, grouped by server, with the ones that change something
+marked.
+
+Picking a tool here is not granting it. The offer is still intersected with what the Bot was granted,
+so a skill naming a tool its Bot does not hold selects the skill and loads nothing — which is why
+anybody may write a skill while connecting a server stays an administrator's decision. The screen says
+so, next to the choice.
+
+A tool the skill names that no connected server offers is shown too, under its own heading, rather
+than left out. A package ships skills declaring tools for connectors nobody has added yet, and a
+skill outlives the server it was written against, so a screen that drew only what matched was
+stating part of the declaration as though it were all of it.
+
## 0.0.3
### A Bot is offered the tools its message needs, not every tool it holds
diff --git a/app/src/components/skills/edit-skill.tsx b/app/src/components/skills/edit-skill.tsx
index f4b4cd04..6e2cdf44 100644
--- a/app/src/components/skills/edit-skill.tsx
+++ b/app/src/components/skills/edit-skill.tsx
@@ -62,6 +62,9 @@ export function EditSkill({ slug }: { slug: string }) {
title: skill.title,
summary: skill.summary ?? "",
instructions: skill.instructions,
+ // Defaulted, so a skill written before this field existed opens with nothing ticked rather
+ // than with an undefined the form would refuse to submit.
+ tools: skill.tools ?? [],
}}
error={saveSkill.error}
/*
diff --git a/app/src/components/skills/skill-fields.tsx b/app/src/components/skills/skill-fields.tsx
index f2c15597..8f6fd8ef 100644
--- a/app/src/components/skills/skill-fields.tsx
+++ b/app/src/components/skills/skill-fields.tsx
@@ -1,4 +1,5 @@
import { useForm } from "@tanstack/react-form";
+import { SkillTools } from "@/components/skills/skill-tools";
import { Button } from "@/components/ui/button";
import {
Field,
@@ -188,6 +189,14 @@ export function SkillFields({
);
}}
+
+ {(field) => (
+
+ )}
+
{/*
diff --git a/app/src/components/skills/skill-tools.tsx b/app/src/components/skills/skill-tools.tsx
new file mode 100644
index 00000000..90633864
--- /dev/null
+++ b/app/src/components/skills/skill-tools.tsx
@@ -0,0 +1,173 @@
+import { useQuery } from "@tanstack/react-query";
+import { Button } from "@/components/ui/button";
+import { Field, FieldLabel } from "@/components/ui/field";
+import { pluginsPageQueryOptions } from "@/lib/plugins/queries";
+import { undeclaredElsewhere } from "@/lib/skills/form";
+
+/**
+ * Which tools this skill says it needs.
+ *
+ * WHY IT MATTERS THAT THIS EXISTS AT ALL. Selection is over skills: before a run the deployment asks
+ * its own model which skills the message needs, and the Bot is built with those skills' tools plus
+ * every granted tool no skill claims. A deployment where no skill declares anything has nothing to
+ * select on, so every Bot is offered its whole catalogue — which is the case the narrowing was built
+ * to fix. Until this screen existed the only way to declare a tool was to call the API by hand.
+ *
+ * A DECLARATION IS NOT A GRANT, and this is the one thing the screen has to get across. Anybody
+ * signed in may write a skill; adding an MCP server is an administrator's decision. If naming a tool
+ * here made it callable, the first surface would be a way around the second. It does not: the offer
+ * is intersected with what the Bot was granted, so a skill naming a tool its Bot does not hold
+ * selects the skill and loads nothing. Said in the hint rather than left for somebody to discover,
+ * because the failure it prevents is somebody ticking boxes expecting access.
+ *
+ * PART OF THE FORM, unlike the Agents toggles below it. What a skill needs is the author's draft
+ * until they press save, so unticking one and closing the panel changes nothing. Granting is the
+ * opposite and saves on press, which is why the two look different on purpose.
+ */
+export function SkillTools({
+ selected,
+ onChange,
+}: {
+ selected: string[];
+ onChange: (refs: string[]) => void;
+}) {
+ const plugins = useQuery(pluginsPageQueryOptions());
+ const held = new Set(selected);
+
+ const toggle = (ref: string) => {
+ /*
+ * Rebuilt rather than mutated, and filtered rather than spliced, so the array handed to the form
+ * is a new one. React Form compares by reference; mutating in place leaves the field's value
+ * looking unchanged and the submit button disabled on a form the person has edited.
+ */
+ onChange(
+ held.has(ref)
+ ? selected.filter((each) => each !== ref)
+ : [...selected, ref],
+ );
+ };
+
+ /*
+ * Only servers that actually offer something. A server whose tools have never been refreshed has
+ * an empty list, and a heading with nothing under it reads as a tool that failed to draw.
+ */
+ const servers = (plugins.data?.servers ?? []).filter(
+ (server) => server.tools.length > 0,
+ );
+
+ /*
+ * Declared, and offered by nothing this deployment has connected.
+ *
+ * Shown rather than dropped, because the alternative is a screen that states part of the
+ * declaration as though it were all of it. A package ships skills declaring tools for connectors
+ * nobody has added yet, and a person's own skill outlives the server it was written against, so
+ * this is the ordinary case rather than a corner of one. Left out, a skill needing two tools drew
+ * one and the second was invisible to the person governing it.
+ *
+ * Only computed once the list has actually loaded: while the query is pending every ref looks
+ * unmatched, and flashing the whole declared set into this group and out again would be worse
+ * than showing nothing for a moment.
+ */
+ const elsewhere = plugins.data
+ ? undeclaredElsewhere(
+ selected,
+ servers.flatMap((server) => server.tools.map((tool) => tool.ref)),
+ )
+ : [];
+
+ return (
+
+ Tools it needs
+
+ {plugins.isPending ? null : plugins.error ? (
+
+ Could not load the tools this deployment has.
+
+ ) : servers.length === 0 ? (
+
+ No connected server offers a tool yet. A skill can still be written —
+ most are instructions rather than tool use.
+
+ This skill names these, and no server connected here offers them —
+ because the connector has not been added, or was removed. They cost
+ nothing and load nothing until it exists. Click one to stop naming
+ it.
+
+
+ ) : null}
+
+
+ Picking this skill is what loads these tools for a turn. It does not
+ grant them — a Bot still only calls what it was granted, so naming a
+ tool here gives nobody access to it.
+
+
+ );
+}
diff --git a/app/src/lib/plugins/mutations.ts b/app/src/lib/plugins/mutations.ts
index e2e2228c..7ead8df7 100644
--- a/app/src/lib/plugins/mutations.ts
+++ b/app/src/lib/plugins/mutations.ts
@@ -17,6 +17,14 @@ export type SkillInput = {
summary?: string;
instructions: string;
global?: boolean;
+ /**
+ * The tools this skill says it needs, as `/` refs.
+ *
+ * Sent on every save, including empty, because the server replaces the set rather than merging
+ * into it: omitting the field to mean "leave them alone" and sending `[]` to mean "clear them"
+ * would be the same request from a form that just had its last one unticked.
+ */
+ tools?: string[];
};
/** A curated server from the catalogue, which supplies the URL. */
diff --git a/app/src/lib/plugins/queries.ts b/app/src/lib/plugins/queries.ts
index 9c7241f5..27accab6 100644
--- a/app/src/lib/plugins/queries.ts
+++ b/app/src/lib/plugins/queries.ts
@@ -41,6 +41,15 @@ export type PluginSkill = {
origin: string;
installedBy: string | null;
grantedTo: string[];
+ /**
+ * The tools this skill says it needs, as `/` refs.
+ *
+ * A declaration, not a grant, and the difference is the whole reason anybody may write a skill:
+ * naming a tool here cannot make it callable. Selection intersects this with what the Bot was
+ * granted, so a skill naming a tool its Bot does not hold selects the skill and loads nothing.
+ * `grantedTo` on the tool side is what decides.
+ */
+ tools: string[];
};
export type CatalogueItem = {
diff --git a/app/src/lib/skills/form.ts b/app/src/lib/skills/form.ts
index 596243b1..74baed8d 100644
--- a/app/src/lib/skills/form.ts
+++ b/app/src/lib/skills/form.ts
@@ -34,6 +34,17 @@ export const skillFormSchema = z.object({
.string()
.trim()
.min(1, "Instructions are required — this is what the Bot follows."),
+ /**
+ * The tools this skill says it needs, as `/` refs.
+ *
+ * No minimum, and no validation of the refs themselves. A skill needing no tool is ordinary — most
+ * are prose — and the server already refuses a ref it has never seen with a sentence naming it,
+ * which is a better answer than anything this file could reconstruct about which tools exist.
+ *
+ * Part of the form rather than saved on press, unlike granting. What a skill needs is the author's
+ * draft until they save it, so unticking one and closing the panel has to change nothing.
+ */
+ tools: z.array(z.string()),
});
export type SkillFormValues = z.infer;
@@ -43,4 +54,27 @@ export const emptySkillForm: SkillFormValues = {
title: "",
summary: "",
instructions: "",
+ tools: [],
};
+
+/**
+ * The declared refs no connected server offers.
+ *
+ * WHY THIS EXISTS. The picker draws the tools of the servers this deployment has connected, and a
+ * skill's declared set is not confined to those: a package ships skills declaring tools for
+ * connectors nobody has added yet, and a person's own skill outlives the server it was written
+ * against. Rendering only what matched meant the screen showed a subset of the declaration and
+ * presented it as the whole thing — a skill needing two tools drew one, and nothing said the other
+ * was there. Editing it silently kept a tool the author had just been shown they did not have.
+ *
+ * A wrong number on a screen somebody governs with is worse than no number, so the leftovers are
+ * named rather than dropped. They are not an error: a ref for a connector that does not exist here
+ * loads nothing, because the offer is intersected with the Bot's grants.
+ */
+export function undeclaredElsewhere(
+ selected: readonly string[],
+ offered: readonly string[],
+): string[] {
+ const known = new Set(offered);
+ return selected.filter((ref) => !known.has(ref));
+}
diff --git a/app/tests/skill-form.test.ts b/app/tests/skill-form.test.ts
new file mode 100644
index 00000000..0d35729a
--- /dev/null
+++ b/app/tests/skill-form.test.ts
@@ -0,0 +1,115 @@
+import { describe, expect, test } from "bun:test";
+import {
+ emptySkillForm,
+ skillFormSchema,
+ undeclaredElsewhere,
+} from "@/lib/skills/form";
+
+/**
+ * The declared tools, as the form carries them.
+ *
+ * A skill needing no tool is the ordinary case, and a skill that had its last one unticked has to
+ * submit as an empty array rather than as an absent field — the server replaces the set when the
+ * field is present and leaves it alone when it is not, so those two are different requests.
+ */
+
+const valid = {
+ slug: "standup",
+ title: "Standup",
+ summary: "",
+ instructions: "Summarise yesterday.",
+};
+
+describe("declaring the tools a skill needs", () => {
+ test("a new skill starts with none declared", () => {
+ expect(emptySkillForm.tools).toEqual([]);
+ // And the empty form is a shape the schema accepts, apart from the fields a person has to fill.
+ expect(
+ skillFormSchema.safeParse({ ...emptySkillForm, ...valid }).success,
+ ).toBeTrue();
+ });
+
+ test("no tools is valid, and stays an array", () => {
+ const parsed = skillFormSchema.safeParse({ ...valid, tools: [] });
+ expect(parsed.success).toBeTrue();
+ // Present rather than stripped: this is what clears the set on a skill that used to declare one.
+ expect(parsed.success && parsed.data.tools).toEqual([]);
+ });
+
+ test("refs are carried through as written", () => {
+ // `/`, the same key a grant is written against. The form does not reformat
+ // them, because the server compares them to grant refs character for character.
+ const tools = ["acme-docs/find_document", "acme-chat/search_messages"];
+ const parsed = skillFormSchema.safeParse({ ...valid, tools });
+ expect(parsed.success && parsed.data.tools).toEqual(tools);
+ });
+
+ test("the field is required, so a save always says what the set is now", () => {
+ // Omitting it would submit a save the server reads as "leave the tools alone", which is not what
+ // a form with nothing ticked means.
+ expect(skillFormSchema.safeParse(valid).success).toBeFalse();
+ });
+
+ test("a ref this deployment has never seen is left to the server", () => {
+ /*
+ * Not validated here on purpose. The server refuses an unknown ref with a sentence naming it, and
+ * it is the only side that knows which tools exist — a list reconstructed in the browser would go
+ * stale the moment a server's tools were refreshed.
+ */
+ expect(
+ skillFormSchema.safeParse({ ...valid, tools: ["nope/not_a_tool"] })
+ .success,
+ ).toBeTrue();
+ });
+});
+
+/**
+ * Declared refs the picker cannot draw as a tool.
+ *
+ * The picker lists the tools of the servers this deployment has connected, and a declaration is not
+ * confined to those: a package ships skills naming tools for connectors nobody has added yet, and a
+ * person's own skill outlives the server it was written against. Anything left over has to be shown,
+ * or the screen states part of the declaration as though it were the whole of it.
+ */
+describe("declared tools no connected server offers", () => {
+ const offered = [
+ "google-drive/search_files",
+ "google-drive/read_file_content",
+ ];
+
+ test("a ref for a connector nobody has added is surfaced", () => {
+ expect(
+ undeclaredElsewhere(
+ ["google-drive/search_files", "jira/search_issues"],
+ offered,
+ ),
+ ).toEqual(["jira/search_issues"]);
+ });
+
+ test("a fully matched declaration leaves nothing over", () => {
+ expect(undeclaredElsewhere(offered, offered)).toEqual([]);
+ });
+
+ test("declaring nothing leaves nothing over", () => {
+ expect(undeclaredElsewhere([], offered)).toEqual([]);
+ });
+
+ test("with no server connected, every declared ref is left over", () => {
+ // The case a fresh clone is in: the package ships skills declaring Drive tools and Drive has not
+ // been connected. Every one of them has to be visible, or the skill reads as declaring nothing.
+ expect(
+ undeclaredElsewhere(
+ ["google-drive/search_files", "google-drive/read_file_content"],
+ [],
+ ),
+ ).toEqual(["google-drive/search_files", "google-drive/read_file_content"]);
+ });
+
+ test("order is the declaration's, so the list does not reshuffle as servers connect", () => {
+ expect(undeclaredElsewhere(["z/one", "a/two", "m/three"], [])).toEqual([
+ "z/one",
+ "a/two",
+ "m/three",
+ ]);
+ });
+});