diff --git a/docs/knowledge-graph.md b/docs/knowledge-graph.md
index 1135f7e22..019a399830 100644
--- a/docs/knowledge-graph.md
+++ b/docs/knowledge-graph.md
@@ -133,22 +133,48 @@ security event log as `LEARNING_CHANGED`. Settings belong to the workspace
membership, so the same person can have different settings in two workspaces.
Cloud and self-hosted behave the same.
-### Skills are instructions, not extra tools
-
-> **Important:** applied skills do **not** add tool calls to your MCP surface.
-> All applied skills for a server (its own server-scoped skills plus the
-> connector-scoped ones for its connectors) are concatenated into a single
-> `## Workspace skills` block inside the server's `instructions`. The agent reads
-> them as guidance — the tool list is unchanged.
+### How skills reach the AI client
+
+A server's applied skills (its own server-scoped skills plus the
+connector-scoped ones of the connectors the caller can use) reach the client in
+two ways:
+
+- **In the instructions** sent on connect, as a `## Workspace skills` block.
+ Each MCP server chooses how (MCP server page → *Workspace skills on connect*):
+ - **Automatic** (default): the full text while it fits in about 6,000
+ characters, otherwise an index of ids, titles and *when to use*.
+ - **Always the full text.**
+ - **Always a list** (the index).
+- **On demand**, with two built-in tools that appear on `/mcp/` when
+ the caller can see at least one applied skill:
+ - `skills_list(query?)` lists or searches the skills;
+ - `skill_get(skill)` returns one skill in full, by id or exact title.
+
+ Both read the current version at call time and only return skills the caller
+ can see on that server.
+
+Each skill is also offered as an **MCP prompt**, so clients with a prompt
+picker (slash commands) can start one by hand.
+
+A skill has a short **instruction** (up to 2,000 characters) and an optional
+**procedure** (markdown, up to 20,000 characters) for the full steps of a
+workflow. The procedure is never pasted into the instructions: the full-text
+mode mentions it with its id, and `skill_get` returns it. The Agent Skills
+Finder's `skills_save_to_workspace` can file a procedure too.
+
+The shared `/mcp` endpoint keeps its fixed toolset: its workspace guide
+(`anythingmcp_get_workspace_guide`) returns the skills as text, procedures
+included.
+
+**Versions and usage.** Editing a skill's title, *when to use*, instruction or
+procedure keeps the previous content as a version (see *History* on the skill)
+and increments its version number. Each `skill_get` or prompt use is counted, so
+the Skills page shows how often a skill was opened and when. Consolidate with
+AI leaves skills that have a procedure untouched.
Because composition is dynamic, editing or deleting a skill takes effect on the
-next request — there's no blob to clean up.
-
-> **Scaling note.** Applied skills are concatenated verbatim into the server's
-> instruction block, so with many active skills that block grows and consumes
-> context on every session. Keep the applied set tight: scope skills to the
-> right server/connector, dismiss duplicates, and use **Consolidate with AI** to
-> merge overlapping active skills into fewer rules.
+next request (a stateful session keeps the instructions it started with; the
+tools always read the current version).
---
diff --git a/packages/backend/prisma/migrations/20261019140000_skills_v2/migration.sql b/packages/backend/prisma/migrations/20261019140000_skills_v2/migration.sql
new file mode 100644
index 000000000..dae21bd00
--- /dev/null
+++ b/packages/backend/prisma/migrations/20261019140000_skills_v2/migration.sql
@@ -0,0 +1,47 @@
+-- Workspace skills v2: long procedures served on demand, versions, usage,
+-- and a per-server choice of how skills reach the model on initialize.
+ALTER TABLE "kg_skill_suggestions" ADD COLUMN "body" TEXT;
+ALTER TABLE "kg_skill_suggestions" ADD COLUMN "version" INTEGER NOT NULL DEFAULT 1;
+ALTER TABLE "kg_skill_suggestions" ADD COLUMN "use_count" INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE "kg_skill_suggestions" ADD COLUMN "last_used_at" TIMESTAMP(3);
+
+ALTER TABLE "mcp_server_configs" ADD COLUMN "skills_delivery" TEXT NOT NULL DEFAULT 'auto';
+
+CREATE TABLE "kg_skill_versions" (
+ "id" TEXT NOT NULL,
+ "skill_id" TEXT NOT NULL,
+ "organization_id" TEXT NOT NULL,
+ "version" INTEGER NOT NULL,
+ "title" TEXT NOT NULL,
+ "when_to_use" TEXT NOT NULL,
+ "instruction" TEXT NOT NULL,
+ "body" TEXT,
+ "edited_by_user_id" TEXT,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "kg_skill_versions_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX "kg_skill_versions_skill_id_version_key" ON "kg_skill_versions"("skill_id", "version");
+CREATE INDEX "kg_skill_versions_organization_id_idx" ON "kg_skill_versions"("organization_id");
+
+ALTER TABLE "kg_skill_versions" ADD CONSTRAINT "kg_skill_versions_skill_id_fkey" FOREIGN KEY ("skill_id") REFERENCES "kg_skill_suggestions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- `auto` switches to an index once the skills text passes 6,000 characters.
+-- Keep today's behaviour (full text) on every server that receives applied
+-- skills today, its own or its connectors', so no existing server changes
+-- until an admin picks a mode. New servers start on `auto`.
+UPDATE "mcp_server_configs" AS s
+SET "skills_delivery" = 'inline'
+WHERE EXISTS (
+ SELECT 1
+ FROM "kg_skill_suggestions" AS k
+ WHERE k."status" = 'applied'
+ AND k."organization_id" = s."organization_id"
+ AND (
+ k."mcp_server_id" = s."id"
+ OR k."connector_id" IN (
+ SELECT c."connector_id" FROM "mcp_server_connectors" AS c WHERE c."mcp_server_id" = s."id"
+ )
+ )
+);
diff --git a/packages/backend/prisma/schema.prisma b/packages/backend/prisma/schema.prisma
index 4e24d5ff9..a5fb1c1da 100644
--- a/packages/backend/prisma/schema.prisma
+++ b/packages/backend/prisma/schema.prisma
@@ -621,6 +621,10 @@ model McpServerConfig {
version String @default("1.0.0")
isActive Boolean @default(true) @map("is_active")
instructions String? @db.Text // custom instructions prepended to composed connector instructions
+ /// How applied skills reach the model on initialize: `auto` (full text
+ /// while small, else an index pointing to skill_get), `inline` (always the
+ /// full text) or `index` (always the index).
+ skillsDelivery String @default("auto") @map("skills_delivery")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -1379,6 +1383,15 @@ model KgSkillSuggestion {
// consolidated) | finder (skills_save_to_workspace). Generation only
// replaces its own pending `llm` rows.
source String @default("manual")
+ // Optional long procedure (markdown). Never pasted into the instructions:
+ // served on demand by skill_get.
+ body String? @db.Text
+ // Incremented on every edit of title / whenToUse / instruction / body; the
+ // previous content is kept in kg_skill_versions.
+ version Int @default(1)
+ useCount Int @default(0) @map("use_count")
+ lastUsedAt DateTime? @map("last_used_at")
+ versions KgSkillVersion[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -1387,6 +1400,25 @@ model KgSkillSuggestion {
@@map("kg_skill_suggestions")
}
+// Earlier content of a workspace skill, written when an edit changes it.
+model KgSkillVersion {
+ id String @id @default(cuid())
+ skillId String @map("skill_id")
+ skill KgSkillSuggestion @relation(fields: [skillId], references: [id], onDelete: Cascade)
+ organizationId String @map("organization_id")
+ version Int
+ title String
+ whenToUse String @db.Text @map("when_to_use")
+ instruction String @db.Text
+ body String? @db.Text
+ editedByUserId String? @map("edited_by_user_id")
+ createdAt DateTime @default(now()) @map("created_at")
+
+ @@unique([skillId, version])
+ @@index([organizationId])
+ @@map("kg_skill_versions")
+}
+
// A submitted LLM batch job (cloud cron, cost-saver). Tracks the provider batch
// id so a later cron run can retrieve results and apply them. `context` maps each
// request's custom_id to what's needed to apply its result (org, type, ref map).
diff --git a/packages/backend/src/knowledge-graph/kg-resource.spec.ts b/packages/backend/src/knowledge-graph/kg-resource.spec.ts
index 64a51c568..27e167d75 100644
--- a/packages/backend/src/knowledge-graph/kg-resource.spec.ts
+++ b/packages/backend/src/knowledge-graph/kg-resource.spec.ts
@@ -59,7 +59,7 @@ describe('KgService.describeForResource', () => {
const { svc } = make({
nodes: [node('n1', 'c1', 'Customer'), node('n2', 'c2', 'Order', { description: 'A sales order.' })],
edges: [{ sourceNodeId: 'n2', targetNodeId: 'n1', kind: 'references', matchKey: 'customer_id', note: null }],
- skills: [{ title: 'Monthly revenue', whenToUse: 'When asked for revenue per month.' }],
+ skills: [{ id: 'sk-rev', title: 'Monthly revenue', whenToUse: 'When asked for revenue per month.' }],
});
const text = await svc.describeForResource(ORG, { connectorIds: ['c1', 'c2'], serverName: 'Sales' });
@@ -69,7 +69,7 @@ describe('KgService.describeForResource', () => {
expect(text).toContain('`c1_get_customer`');
expect(text).toContain('`customer_number`');
expect(text).toContain('- Order (ERP) refers to Customer (CRM) via `customer_id`');
- expect(text).toContain('- **Monthly revenue**: When asked for revenue per month.');
+ expect(text).toContain('- **Monthly revenue** (`sk-rev`): When asked for revenue per month.');
});
it('stays bounded on a large graph and says what it left out', async () => {
diff --git a/packages/backend/src/knowledge-graph/kg-skill.service.spec.ts b/packages/backend/src/knowledge-graph/kg-skill.service.spec.ts
index 3e5dc0921..c59fc99ef 100644
--- a/packages/backend/src/knowledge-graph/kg-skill.service.spec.ts
+++ b/packages/backend/src/knowledge-graph/kg-skill.service.spec.ts
@@ -281,3 +281,148 @@ describe('KgSkillService.activeSkillsText', () => {
);
});
});
+
+describe('KgSkillService delivery modes', () => {
+ const ORG = 'org-A';
+ const row = (i: number, extra: Partial = {}) => ({
+ id: `s${i}`,
+ title: `Skill ${i}`,
+ whenToUse: `when ${i}`,
+ instruction: `rule ${i}`,
+ body: null,
+ version: 1,
+ ...extra,
+ });
+ const svcWith = (rows: any[]) =>
+ new KgSkillService({ kgSkillSuggestion: { findMany: jest.fn().mockResolvedValue(rows) } } as any, {} as any);
+
+ it('inline keeps the existing format for skills without a procedure', async () => {
+ const text = await svcWith([row(1)]).activeSkillsText(ORG, 'srv', [], 'inline');
+ expect(text).toBe('## Workspace skills\n- Skill 1 (when: when 1): rule 1');
+ });
+
+ it('inline references a procedure instead of pasting it', async () => {
+ const text = await svcWith([row(1, { body: 'STEP-BY-STEP' })]).activeSkillsText(ORG, 'srv', [], 'inline');
+ expect(text).toContain('skill_get "s1"');
+ expect(text).not.toContain('STEP-BY-STEP');
+ });
+
+ it('full pastes the procedure (shared guide)', async () => {
+ const text = await svcWith([row(1, { body: 'STEP-BY-STEP' })]).activeSkillsText(ORG, 'srv', [], 'full');
+ expect(text).toContain('STEP-BY-STEP');
+ });
+
+ it('auto stays inline while small and switches to the index when large', async () => {
+ const small = await svcWith([row(1)]).activeSkillsText(ORG, 'srv', [], 'auto');
+ expect(small).toContain('rule 1');
+
+ const big = Array.from({ length: 40 }, (_, i) => row(i, { instruction: 'x'.repeat(300) }));
+ const text = (await svcWith(big).activeSkillsText(ORG, 'srv', [], 'auto'))!;
+ expect(text).toContain('call `skill_get`');
+ expect(text).toContain('- `s0` Skill 0: when 0');
+ expect(text).not.toContain('x'.repeat(300));
+ });
+
+ it('index lists up to its budget and points to skills_list for the rest', async () => {
+ const many = Array.from({ length: 200 }, (_, i) => row(i, { whenToUse: 'w'.repeat(150) }));
+ const text = (await svcWith(many).activeSkillsText(ORG, 'srv', [], 'index'))!;
+ expect(text.length).toBeLessThan(8_200);
+ expect(text).toMatch(/…and \d+ more: call `skills_list`/);
+ });
+});
+
+describe('KgSkillService.getForCaller', () => {
+ it('reads only visible applied skills of the organization and counts the use', async () => {
+ const skill = { id: 's1', title: 'T', whenToUse: '', instruction: 'I', body: null, version: 2 };
+ const findFirst = jest.fn().mockResolvedValue(skill);
+ const update = jest.fn().mockResolvedValue({});
+ const svc = new KgSkillService({ kgSkillSuggestion: { findFirst, update } } as any, {} as any);
+
+ expect(await svc.getForCaller('org-A', 'srv-1', ['c-1'], 's1')).toBe(skill);
+ const where = findFirst.mock.calls[0][0].where;
+ expect(where.AND[0]).toEqual({
+ organizationId: 'org-A',
+ status: 'applied',
+ OR: [{ mcpServerId: { in: ['srv-1'] } }, { connectorId: { in: ['c-1'] } }],
+ });
+ expect(where.AND[1]).toEqual({ OR: [{ id: 's1' }, { title: { equals: 's1', mode: 'insensitive' } }] });
+ expect(update).toHaveBeenCalledWith({
+ where: { id: 's1' },
+ data: { useCount: { increment: 1 }, lastUsedAt: expect.any(Date) },
+ });
+ });
+
+ it('returns null without counting when the skill is not visible', async () => {
+ const update = jest.fn();
+ const svc = new KgSkillService(
+ { kgSkillSuggestion: { findFirst: jest.fn().mockResolvedValue(null), update } } as any,
+ {} as any,
+ );
+ expect(await svc.getForCaller('org-A', 'srv-1', [], 'other-org-skill')).toBeNull();
+ expect(update).not.toHaveBeenCalled();
+ });
+});
+
+describe('KgSkillService.update versions', () => {
+ const current = {
+ id: 's1',
+ organizationId: 'org-A',
+ title: 'Old',
+ whenToUse: 'w',
+ instruction: 'old rule',
+ body: null,
+ version: 3,
+ };
+
+ function make() {
+ const tx = {
+ kgSkillVersion: { create: jest.fn() },
+ kgSkillSuggestion: { update: jest.fn().mockResolvedValue({ id: 's1', version: 4 }) },
+ };
+ const prisma = {
+ kgSkillSuggestion: {
+ findUnique: jest.fn().mockResolvedValue(current),
+ update: jest.fn().mockResolvedValue({ id: 's1' }),
+ },
+ $transaction: jest.fn(async (fn: any) => fn(tx)),
+ };
+ return { svc: new KgSkillService(prisma as any, {} as any), prisma, tx };
+ }
+
+ it('keeps the previous content as a version and increments it on a content change', async () => {
+ const { svc, tx } = make();
+ await svc.update('org-A', 's1', { instruction: 'new rule', body: 'steps' }, 'u-1');
+ expect(tx.kgSkillVersion.create).toHaveBeenCalledWith({
+ data: {
+ skillId: 's1',
+ organizationId: 'org-A',
+ version: 3,
+ title: 'Old',
+ whenToUse: 'w',
+ instruction: 'old rule',
+ body: null,
+ editedByUserId: 'u-1',
+ },
+ });
+ expect(tx.kgSkillSuggestion.update).toHaveBeenCalledWith({
+ where: { id: 's1' },
+ data: { instruction: 'new rule', body: 'steps', version: { increment: 1 } },
+ });
+ });
+
+ it('does not version a status-only change', async () => {
+ const { svc, prisma } = make();
+ await svc.update('org-A', 's1', { status: 'dismissed' });
+ expect(prisma.$transaction).not.toHaveBeenCalled();
+ expect(prisma.kgSkillSuggestion.update).toHaveBeenCalledWith({
+ where: { id: 's1' },
+ data: { status: 'dismissed' },
+ });
+ });
+
+ it('refuses a skill of another organization', async () => {
+ const { svc, prisma } = make();
+ prisma.kgSkillSuggestion.findUnique.mockResolvedValue({ ...current, organizationId: 'org-B' });
+ await expect(svc.update('org-A', 's1', { title: 'x' })).rejects.toBeInstanceOf(NotFoundException);
+ });
+});
diff --git a/packages/backend/src/knowledge-graph/kg-skill.service.ts b/packages/backend/src/knowledge-graph/kg-skill.service.ts
index 6430cc4ef..49e8fe95f 100644
--- a/packages/backend/src/knowledge-graph/kg-skill.service.ts
+++ b/packages/backend/src/knowledge-graph/kg-skill.service.ts
@@ -9,6 +9,87 @@ const MAX_INTENTS = 200;
/** Where a skill came from; generation only ever replaces its own `llm` rows. */
export type SkillSource = 'manual' | 'llm' | 'finder';
+/** Longest procedure (`body`) a skill can carry. Served by skill_get only. */
+export const SKILL_BODY_MAX = 20_000;
+
+/** How applied skills reach the model on initialize (per MCP server). */
+export const SKILLS_DELIVERY_MODES = ['auto', 'inline', 'index'] as const;
+export type SkillsDelivery = (typeof SKILLS_DELIVERY_MODES)[number];
+
+/**
+ * `auto` keeps the full text up to this many characters, then switches to the
+ * index. The skills_v2 migration pins servers already above it to `inline`;
+ * keep both numbers equal.
+ */
+export const SKILLS_INLINE_BUDGET = 6_000;
+/** The index stops listing entries past this size and points to skills_list. */
+const SKILLS_INDEX_BUDGET = 8_000;
+const INDEX_WHEN_MAX = 200;
+
+/** Built-in MCP tool names (per-server endpoint). */
+export const SKILLS_LIST_TOOL = 'skills_list';
+export const SKILL_GET_TOOL = 'skill_get';
+
+interface SkillRow {
+ id: string;
+ title: string;
+ whenToUse: string;
+ instruction: string;
+ body: string | null;
+ version: number;
+}
+
+/** The skills a caller may see: the server's own and its visible connectors'. */
+function visibleWhere(organizationId: string, serverIds: string[], connectorIds: string[]) {
+ return {
+ // Re-stated rather than trusted from the ids, so a stray id can never
+ // pull another workspace's skills in.
+ organizationId,
+ status: 'applied',
+ OR: [
+ { mcpServerId: { in: serverIds.length ? serverIds : ['__none__'] } },
+ { connectorId: { in: connectorIds.length ? connectorIds : ['__none__'] } },
+ ],
+ };
+}
+
+function oneLine(text: string, max: number): string {
+ const flat = text.replace(/\s+/g, ' ').trim();
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
+}
+
+function inlineText(rows: SkillRow[], withBodies: boolean): string {
+ const body = rows
+ .map((s) => {
+ const head = `- ${s.title}${s.whenToUse ? ` (when: ${s.whenToUse})` : ''}: ${s.instruction}`;
+ if (!s.body) return head;
+ return withBodies
+ ? `${head}\n\n${s.body}\n`
+ : `${head} (full procedure: ${SKILL_GET_TOOL} "${s.id}")`;
+ })
+ .join('\n');
+ return `## Workspace skills\n${body}`;
+}
+
+function indexText(rows: SkillRow[]): string {
+ const head =
+ `## Workspace skills\n` +
+ `This workspace has ${rows.length} skill${rows.length === 1 ? '' : 's'}: its procedures and rules. ` +
+ `Before a task that matches one, call \`${SKILL_GET_TOOL}\` with its id and follow it. ` +
+ `\`${SKILLS_LIST_TOOL}\` searches them all.`;
+ const lines: string[] = [];
+ let size = head.length;
+ for (const s of rows) {
+ const line = `- \`${s.id}\` ${s.title}${s.whenToUse ? `: ${oneLine(s.whenToUse, INDEX_WHEN_MAX)}` : ''}`;
+ if (size + line.length + 1 > SKILLS_INDEX_BUDGET) break;
+ lines.push(line);
+ size += line.length + 1;
+ }
+ const rest = rows.length - lines.length;
+ if (rest > 0) lines.push(`- …and ${rest} more: call \`${SKILLS_LIST_TOOL}\`.`);
+ return `${head}\n${lines.join('\n')}`;
+}
+
/** Auto-apply a generated skill only when at/above this confidence. */
export const SKILL_AUTO_APPLY_MIN = 0.9;
@@ -320,9 +401,11 @@ export class KgSkillService {
}
}
+ // Skills with a procedure are left out: the merge works on the short
+ // rules only and would drop the procedure.
const where = mcpServerId
- ? { organizationId, status: 'applied', mcpServerId }
- : { organizationId, status: 'applied', mcpServerId: null };
+ ? { organizationId, status: 'applied', mcpServerId, body: null }
+ : { organizationId, status: 'applied', mcpServerId: null, body: null };
const current = await this.prisma.kgSkillSuggestion.findMany({
where,
select: {
@@ -391,32 +474,135 @@ export class KgSkillService {
return { before: current.length, after, model: cfg.model, usage };
}
- /** Active skills composed into an MCP server's instructions at serve time. */
+ /**
+ * Applied skills as instruction text. `inline` is the full text (procedures
+ * referenced, not pasted), `index` lists titles for skill_get, `auto` picks
+ * inline while it fits SKILLS_INLINE_BUDGET, `full` pastes the procedures
+ * too (the shared /mcp guide, which has no skill_get).
+ */
async activeSkillsText(
organizationId: string,
serverId: string | string[],
connectorIds: string[],
+ mode: SkillsDelivery | 'full' = 'inline',
): Promise {
const serverIds = Array.isArray(serverId) ? serverId : [serverId];
- const skills = await this.prisma.kgSkillSuggestion.findMany({
+ const rows: SkillRow[] = await this.prisma.kgSkillSuggestion.findMany({
+ where: visibleWhere(organizationId, serverIds, connectorIds),
+ select: { id: true, title: true, whenToUse: true, instruction: true, body: true, version: true },
+ orderBy: { confidence: 'desc' },
+ });
+ if (!rows.length) return null;
+ if (mode === 'full') return inlineText(rows, true);
+ if (mode === 'index') return indexText(rows);
+ const inline = inlineText(rows, false);
+ if (mode === 'inline' || inline.length <= SKILLS_INLINE_BUDGET) return inline;
+ return indexText(rows);
+ }
+
+ /** Whether a caller sees at least one applied skill (offers the skill tools). */
+ async hasVisible(organizationId: string, serverId: string, connectorIds: string[]): Promise {
+ const n = await this.prisma.kgSkillSuggestion.count({
+ where: visibleWhere(organizationId, [serverId], connectorIds),
+ });
+ return n > 0;
+ }
+
+ /** skills_list: the caller's visible skills, optionally filtered. */
+ async listForCaller(
+ organizationId: string,
+ serverId: string,
+ connectorIds: string[],
+ query?: string,
+ ) {
+ const q = (query ?? '').trim();
+ const rows = await this.prisma.kgSkillSuggestion.findMany({
where: {
- // Re-stated rather than trusted from the ids, so a stray id can never
- // pull another workspace's skills into these instructions.
- organizationId,
- status: 'applied',
- OR: [
- { mcpServerId: { in: serverIds.length ? serverIds : ['__none__'] } },
- { connectorId: { in: connectorIds.length ? connectorIds : ['__none__'] } },
+ AND: [
+ visibleWhere(organizationId, [serverId], connectorIds),
+ ...(q
+ ? [
+ {
+ OR: [
+ { title: { contains: q, mode: 'insensitive' as const } },
+ { whenToUse: { contains: q, mode: 'insensitive' as const } },
+ { instruction: { contains: q, mode: 'insensitive' as const } },
+ ],
+ },
+ ]
+ : []),
],
},
- select: { title: true, whenToUse: true, instruction: true },
+ select: { id: true, title: true, whenToUse: true, body: true, version: true },
orderBy: { confidence: 'desc' },
+ take: 100,
+ });
+ return rows.map((r) => ({
+ id: r.id,
+ title: r.title,
+ whenToUse: r.whenToUse,
+ version: r.version,
+ hasProcedure: !!r.body,
+ }));
+ }
+
+ /**
+ * skill_get: one visible skill by id or exact title (case-insensitive).
+ * Counts the use. Null when the caller cannot see it, the same answer as
+ * for a skill that does not exist.
+ */
+ async getForCaller(
+ organizationId: string,
+ serverId: string,
+ connectorIds: string[],
+ ref: string,
+ ) {
+ const key = (ref ?? '').trim();
+ if (!key) return null;
+ const skill = await this.prisma.kgSkillSuggestion.findFirst({
+ where: {
+ AND: [
+ visibleWhere(organizationId, [serverId], connectorIds),
+ { OR: [{ id: key }, { title: { equals: key, mode: 'insensitive' as const } }] },
+ ],
+ },
+ select: {
+ id: true,
+ title: true,
+ whenToUse: true,
+ instruction: true,
+ body: true,
+ version: true,
+ updatedAt: true,
+ },
+ });
+ if (!skill) return null;
+ // Best effort: a failed counter must never fail the read.
+ this.prisma.kgSkillSuggestion
+ .update({
+ where: { id: skill.id },
+ data: { useCount: { increment: 1 }, lastUsedAt: new Date() },
+ })
+ .catch((e: any) => this.logger.warn(`skill use count failed: ${e?.message ?? e}`));
+ return skill;
+ }
+
+ /** Earlier versions of one skill, newest first. */
+ async versions(organizationId: string, id: string) {
+ await this.owned(organizationId, id);
+ return this.prisma.kgSkillVersion.findMany({
+ where: { skillId: id, organizationId },
+ orderBy: { version: 'desc' },
+ select: {
+ version: true,
+ title: true,
+ whenToUse: true,
+ instruction: true,
+ body: true,
+ editedByUserId: true,
+ createdAt: true,
+ },
});
- if (!skills.length) return null;
- const body = skills
- .map((s) => `- ${s.title}${s.whenToUse ? ` (when: ${s.whenToUse})` : ''}: ${s.instruction}`)
- .join('\n');
- return `## Workspace skills\n${body}`;
}
/**
@@ -434,6 +620,7 @@ export class KgSkillService {
mcpServerId?: string | null;
status?: string;
source?: SkillSource;
+ body?: string | null;
},
) {
const title = (body.title || '').trim().slice(0, 160);
@@ -479,6 +666,7 @@ export class KgSkillService {
evidenceCount: 0,
status,
source: body.source === 'finder' ? 'finder' : 'manual',
+ body: normalizeBody(body.body),
},
});
}
@@ -499,20 +687,57 @@ export class KgSkillService {
return this.prisma.kgSkillSuggestion.update({ where: { id }, data: { status: 'dismissed' } });
}
+ /**
+ * Edit a skill. A change to its content (title, when, instruction, body)
+ * keeps the previous content as a version and increments `version`; a
+ * status-only change does not.
+ */
async update(
organizationId: string,
id: string,
- patch: { title?: string; whenToUse?: string; instruction?: string; status?: string },
+ patch: {
+ title?: string;
+ whenToUse?: string;
+ instruction?: string;
+ body?: string | null;
+ status?: string;
+ },
+ editedByUserId?: string,
) {
- await this.owned(organizationId, id);
+ const current = await this.owned(organizationId, id);
const data: Record = {};
if (typeof patch.title === 'string') data.title = patch.title.slice(0, 160);
if (typeof patch.whenToUse === 'string') data.whenToUse = patch.whenToUse.slice(0, 1000);
if (typeof patch.instruction === 'string') data.instruction = patch.instruction.slice(0, 2000);
+ if (patch.body !== undefined) data.body = normalizeBody(patch.body);
if (patch.status && ['pending', 'applied', 'dismissed'].includes(patch.status)) {
data.status = patch.status;
}
- return this.prisma.kgSkillSuggestion.update({ where: { id }, data });
+
+ const changed = (['title', 'whenToUse', 'instruction', 'body'] as const).some(
+ (k) => k in data && data[k] !== (current as any)[k],
+ );
+ if (!changed) {
+ return this.prisma.kgSkillSuggestion.update({ where: { id }, data });
+ }
+ return this.prisma.$transaction(async (tx) => {
+ await tx.kgSkillVersion.create({
+ data: {
+ skillId: id,
+ organizationId,
+ version: current.version,
+ title: current.title,
+ whenToUse: current.whenToUse,
+ instruction: current.instruction,
+ body: current.body,
+ editedByUserId: editedByUserId ?? null,
+ },
+ });
+ return tx.kgSkillSuggestion.update({
+ where: { id },
+ data: { ...data, version: { increment: 1 } },
+ });
+ });
}
async remove(organizationId: string, id: string) {
@@ -521,3 +746,10 @@ export class KgSkillService {
return { ok: true };
}
}
+
+/** Trimmed, capped procedure; empty means none. */
+function normalizeBody(body: string | null | undefined): string | null {
+ if (typeof body !== 'string') return null;
+ const trimmed = body.trim();
+ return trimmed ? trimmed.slice(0, SKILL_BODY_MAX) : null;
+}
diff --git a/packages/backend/src/knowledge-graph/kg.controller.ts b/packages/backend/src/knowledge-graph/kg.controller.ts
index 7537305d6..da46f9a0d 100644
--- a/packages/backend/src/knowledge-graph/kg.controller.ts
+++ b/packages/backend/src/knowledge-graph/kg.controller.ts
@@ -21,10 +21,11 @@ import {
IsIn,
IsOptional,
IsString,
+ MaxLength,
} from 'class-validator';
import { Roles, RolesGuard } from '../auth/roles.guard';
import { KgService } from './kg.service';
-import { KgSkillService } from './kg-skill.service';
+import { KgSkillService, SKILL_BODY_MAX } from './kg-skill.service';
import {
KgLearningService,
LEARNING_BULK_MAX,
@@ -44,10 +45,18 @@ class CreateSkillDto {
@IsString()
whenToUse?: string;
- @ApiProperty({ description: 'The rule or procedure (stored up to 2000 characters).' })
+ @ApiProperty({ description: 'The rule (stored up to 2000 characters).' })
@IsString()
instruction: string;
+ @ApiPropertyOptional({
+ description: `Optional full procedure in markdown (up to ${SKILL_BODY_MAX} characters). Served by skill_get, never pasted into the instructions.`,
+ })
+ @IsOptional()
+ @IsString()
+ @MaxLength(SKILL_BODY_MAX)
+ body?: string | null;
+
@ApiPropertyOptional({ description: 'Scope the skill to one connector of this workspace.' })
@IsOptional()
@IsString()
@@ -80,6 +89,12 @@ class UpdateSkillDto {
@IsString()
instruction?: string;
+ @ApiPropertyOptional({ description: 'Full procedure in markdown; empty string or null removes it.' })
+ @IsOptional()
+ @IsString()
+ @MaxLength(SKILL_BODY_MAX)
+ body?: string | null;
+
@ApiPropertyOptional({ enum: SKILL_STATUSES })
@IsOptional()
@IsIn(SKILL_STATUSES)
@@ -257,7 +272,14 @@ export class KgController {
@Param('id') id: string,
@Body() body: UpdateSkillDto,
) {
- return this.skills.update(req.user.organizationId, id, body);
+ return this.skills.update(req.user.organizationId, id, body, req.user.sub);
+ }
+
+ @Get('skills/:id/versions')
+ @Roles('ADMIN', 'EDITOR')
+ @ApiOperation({ summary: 'Earlier versions of a skill, newest first' })
+ async skillVersions(@Req() req: any, @Param('id') id: string) {
+ return this.skills.versions(req.user.organizationId, id);
}
// ── Who contributes to skill learning ────────────────────────────────────
diff --git a/packages/backend/src/knowledge-graph/kg.service.ts b/packages/backend/src/knowledge-graph/kg.service.ts
index 9a37590b4..6cf4eae18 100644
--- a/packages/backend/src/knowledge-graph/kg.service.ts
+++ b/packages/backend/src/knowledge-graph/kg.service.ts
@@ -690,8 +690,9 @@ export class KgService {
if (skills.length) {
out.push('', `## Workspace skills (${skills.length})`, '');
for (const sk of skills) {
- out.push(`- **${sk.title}**${sk.whenToUse ? `: ${oneLine(sk.whenToUse)}` : ''}`);
+ out.push(`- **${sk.title}** (\`${sk.id}\`)${sk.whenToUse ? `: ${oneLine(sk.whenToUse)}` : ''}`);
}
+ out.push('', 'Read a skill in full with `skill_get` and its id.');
}
return out.join('\n');
}
@@ -710,10 +711,10 @@ export class KgService {
status: 'applied',
...(or.length ? { OR: or } : {}),
},
- select: { title: true, whenToUse: true },
+ select: { id: true, title: true, whenToUse: true },
take: 25,
});
- return rows.map((r) => ({ title: r.title, whenToUse: r.whenToUse }));
+ return rows.map((r) => ({ id: r.id, title: r.title, whenToUse: r.whenToUse }));
}
async deleteEdge(organizationId: string, edgeId: string) {
diff --git a/packages/backend/src/mcp-server/grant-visibility.spec.ts b/packages/backend/src/mcp-server/grant-visibility.spec.ts
index d56ad7edd..c5c0fb609 100644
--- a/packages/backend/src/mcp-server/grant-visibility.spec.ts
+++ b/packages/backend/src/mcp-server/grant-visibility.spec.ts
@@ -54,7 +54,7 @@ function build(
{} as any,
{} as any,
{ resolve: jest.fn().mockResolvedValue(grant) } as any,
- { create: jest.fn() } as any,
+ { create: jest.fn(), hasVisible: jest.fn().mockResolvedValue(false) } as any,
);
return controller;
}
@@ -227,7 +227,7 @@ describe('grant-scoped visibility on the shared /mcp', () => {
{} as any,
{} as any,
{ resolve: jest.fn().mockResolvedValue(null) } as any,
- { create: jest.fn() } as any,
+ { create: jest.fn(), hasVisible: jest.fn().mockResolvedValue(false) } as any,
);
const listFor = async (user: any) => {
diff --git a/packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts b/packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts
index 12fbe59d8..7765d9987 100644
--- a/packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts
+++ b/packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts
@@ -70,7 +70,7 @@ describe('McpEndpointController — tenant isolation', () => {
kgService as any,
sessionManager as any,
grants as any,
- { create: jest.fn() } as any,
+ { create: jest.fn(), hasVisible: jest.fn().mockResolvedValue(false) } as any,
);
});
@@ -335,7 +335,7 @@ describe('McpEndpointController — keys created for a server', () => {
} as any,
sessionManager,
{ resolve: jest.fn().mockResolvedValue(null) } as any,
- { create: jest.fn() } as any,
+ { create: jest.fn(), hasVisible: jest.fn().mockResolvedValue(false) } as any,
);
(controller as any).serveStateless = jest.fn().mockResolvedValue(undefined);
});
@@ -451,7 +451,7 @@ describe('McpEndpointController — structuredContent', () => {
{ lookup: jest.fn(), isEnabled: jest.fn(), captureIntentEnabled: jest.fn() } as any,
{ get: jest.fn(), add: jest.fn(), remove: jest.fn() } as any,
{ resolve: jest.fn().mockResolvedValue(null) } as any,
- { create: jest.fn() } as any,
+ { create: jest.fn(), hasVisible: jest.fn().mockResolvedValue(false) } as any,
);
const entries = (controller as any).planToolSet({
@@ -524,7 +524,7 @@ describe('McpEndpointController — structuredContent', () => {
{ lookup: jest.fn(), isEnabled: jest.fn(), captureIntentEnabled: jest.fn() } as any,
{ get: jest.fn(), add: jest.fn(), remove: jest.fn() } as any,
{ resolve: jest.fn().mockResolvedValue(null) } as any,
- { create: jest.fn() } as any,
+ { create: jest.fn(), hasVisible: jest.fn().mockResolvedValue(false) } as any,
);
const entries = (controller as any).planToolSet({
serverTools: [TOOL],
@@ -590,7 +590,7 @@ describe('McpEndpointController — knowledge graph scope', () => {
kgService as any,
{} as any,
{} as any,
- { create: jest.fn() } as any,
+ { create: jest.fn(), hasVisible: jest.fn().mockResolvedValue(false) } as any,
);
return { controller: controller as any, kgService };
}
@@ -957,7 +957,7 @@ describe('McpEndpointController — resources and instructions follow the tool p
kgService,
sessionManager as any,
{ resolve: jest.fn() } as any,
- { create: jest.fn() } as any,
+ { create: jest.fn(), hasVisible: jest.fn().mockResolvedValue(false) } as any,
);
built = undefined;
// Keep the server the request built instead of serving it over HTTP.
@@ -1064,6 +1064,51 @@ describe('McpEndpointController — resources and instructions follow the tool p
await on.close();
});
+ it('offers skills_list, skill_get and skill prompts only when the caller sees a skill', async () => {
+ const none = await connect(null);
+ const noneNames = (await none.listTools()).tools.map((t) => t.name);
+ expect(noneNames).not.toContain('skill_get');
+ expect(noneNames).not.toContain('skills_list');
+ await none.close();
+
+ const skill = {
+ id: 'sk-1',
+ title: 'Angebot erstellen',
+ whenToUse: 'When the user asks for a quote',
+ instruction: 'Always draft first.',
+ body: 'STEP 1: draft_quote',
+ version: 2,
+ };
+ const kgSkills = (controller as any).kgSkills;
+ kgSkills.hasVisible = jest.fn().mockResolvedValue(true);
+ kgSkills.listForCaller = jest.fn().mockResolvedValue([
+ { id: 'sk-1', title: skill.title, whenToUse: skill.whenToUse, version: 2, hasProcedure: true },
+ ]);
+ kgSkills.getForCaller = jest.fn(async (_o: string, _s: string, _c: string[], ref: string) =>
+ ref === 'sk-1' ? skill : null,
+ );
+
+ const client = await connect(['crm_get']);
+ const names = (await client.listTools()).tools.map((t) => t.name);
+ expect(names).toEqual(expect.arrayContaining(['skills_list', 'skill_get']));
+ // Scoped to the server's organization and the caller's visible connectors.
+ expect(kgSkills.hasVisible).toHaveBeenCalledWith(ORG_A, 'srv-1', ['c-crm']);
+
+ const got: any = await client.callTool({ name: 'skill_get', arguments: { skill: 'sk-1' } });
+ expect(got.content[0].text).toContain('STEP 1: draft_quote');
+ expect(got.content[0].text).toContain('version 2');
+ expect(kgSkills.getForCaller).toHaveBeenCalledWith(ORG_A, 'srv-1', ['c-crm'], 'sk-1');
+
+ const missing: any = await client.callTool({ name: 'skill_get', arguments: { skill: 'nope' } });
+ expect(missing.isError).toBe(true);
+
+ const prompts = (await client.listPrompts()).prompts;
+ expect(prompts.map((p) => p.name)).toEqual(['angebot-erstellen']);
+ const prompt = await client.getPrompt({ name: 'angebot-erstellen' });
+ expect((prompt.messages[0].content as any).text).toContain('STEP 1: draft_quote');
+ await client.close();
+ });
+
it('a caller who sees everything keeps the full instructions', async () => {
const client = await connect(null);
const instructions = client.getInstructions() ?? '';
diff --git a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts
index 6b06f5947..86fc66494 100644
--- a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts
+++ b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts
@@ -65,7 +65,12 @@ import { DynamicMcpTools } from './dynamic-mcp-tools';
import { RolesService } from '../roles/roles.service';
import { registerDemoTools } from './mcp-demo.tools';
import { KgService } from '../knowledge-graph/kg.service';
-import { KgSkillService } from '../knowledge-graph/kg-skill.service';
+import {
+ KgSkillService,
+ SKILL_BODY_MAX,
+ SKILL_GET_TOOL,
+ SKILLS_LIST_TOOL,
+} from '../knowledge-graph/kg-skill.service';
import { outputSchemaToZodShape } from '../connectors/output-schema.util';
import {
annotationsSignature,
@@ -177,6 +182,9 @@ interface ToolSetParams {
/** Optional text appended as a trailing content block to every successful
* tool result (used by the public demo to say where the answer came from). */
resultFooter?: string;
+ /** Set when the caller can see at least one applied workspace skill: offers
+ * skills_list / skill_get, scoped to the server's organization. */
+ skills?: { organizationId: string };
}
interface InvocationContext {
@@ -1107,12 +1115,21 @@ export class McpEndpointController {
? await this.kgService.isEnabled(invocationContext.organizationId)
: false;
+ // Workspace skills belong to the server's organization and follow the
+ // caller's visible connectors, like the instructions above.
+ const skillsVisible = await this.kgSkills.hasVisible(
+ mcpServerConfig.organizationId,
+ serverId,
+ visibleConnectorIds,
+ );
+
const params: ToolSetParams = {
serverTools,
allowedToolIds,
captureIntent,
kgEnabled,
invocationContext,
+ skills: skillsVisible ? { organizationId: mcpServerConfig.organizationId } : undefined,
};
// Register this server's tool surface onto the per-request McpServer.
@@ -1120,6 +1137,14 @@ export class McpEndpointController {
const handles = this.registerAll(mcpServer, entries, serverId);
const signature = this.aggregateSig(entries);
this.registerKgResource(mcpServer, params);
+ if (skillsVisible) {
+ await this.registerSkillPrompts(
+ mcpServer,
+ mcpServerConfig.organizationId,
+ serverId,
+ visibleConnectorIds,
+ );
+ }
this.registerServerResources(
mcpServer,
planServerResources({
@@ -1449,12 +1474,18 @@ export class McpEndpointController {
);
}
+ const skillsVisible = await this.kgSkills.hasVisible(
+ serverOrganizationId,
+ serverId,
+ callerConnectorIds(serverTools, allowedToolIds, connectorIds),
+ );
const entries = this.planToolSet({
serverTools,
allowedToolIds,
captureIntent,
kgEnabled,
invocationContext,
+ skills: skillsVisible ? { organizationId: serverOrganizationId } : undefined,
});
const signature = this.aggregateSig(entries);
if (signature === session.signature) return; // nothing changed here
@@ -1682,6 +1713,76 @@ export class McpEndpointController {
});
}
+ // Workspace skills on demand. The instructions carry the skills as text
+ // or, when there are many or they hold long procedures, an index that
+ // points here. Both tools read at call time, so an edited skill is served
+ // fresh, and only skills the caller can see on this server.
+ if (params.skills && invocationContext.mcpServerId) {
+ const orgId = params.skills.organizationId;
+ const serverId = invocationContext.mcpServerId;
+ const scope = this.kgScopeConnectorIds(params);
+ if (!registeredNames.has(SKILLS_LIST_TOOL)) {
+ entries.push({
+ name: SKILLS_LIST_TOOL,
+ sig: `${SKILLS_LIST_TOOL}:v1`,
+ register: (mcpServer: McpServer) =>
+ mcpServer.registerTool(
+ SKILLS_LIST_TOOL,
+ {
+ description:
+ "Lists this workspace's skills: its procedures and rules for recurring tasks. " +
+ 'Returns id, title and when to use each. Pass a query to search titles, ' +
+ `"when to use" and rules. Then call ${SKILL_GET_TOOL} with the id of the one that fits.`,
+ inputSchema: {
+ query: z.string().max(200).optional().describe('Words to search for, e.g. "quote" or "invoice".'),
+ },
+ annotations: { title: 'List workspace skills', readOnlyHint: true, openWorldHint: false },
+ },
+ async (args: { query?: string }) => {
+ const skills = await this.kgSkills.listForCaller(orgId, serverId, scope, args?.query);
+ return {
+ content: [{ type: 'text' as const, text: JSON.stringify({ skills }, null, 2) }],
+ };
+ },
+ ) as unknown as ToolHandle,
+ });
+ }
+ if (!registeredNames.has(SKILL_GET_TOOL)) {
+ entries.push({
+ name: SKILL_GET_TOOL,
+ sig: `${SKILL_GET_TOOL}:v1`,
+ register: (mcpServer: McpServer) =>
+ mcpServer.registerTool(
+ SKILL_GET_TOOL,
+ {
+ description:
+ 'Returns one workspace skill in full: when to use it, its rule and, if it has one, ' +
+ 'the complete procedure. Call it before a task the skill covers and follow it.',
+ inputSchema: {
+ skill: z.string().min(1).max(200).describe('The skill id, or its exact title.'),
+ },
+ annotations: { title: 'Get workspace skill', readOnlyHint: true, openWorldHint: false },
+ },
+ async (args: { skill: string }) => {
+ const found = await this.kgSkills.getForCaller(orgId, serverId, scope, args.skill);
+ if (!found) {
+ return {
+ isError: true,
+ content: [
+ {
+ type: 'text' as const,
+ text: `No skill "${args.skill}" on this server. Call ${SKILLS_LIST_TOOL} to see the available ones.`,
+ },
+ ],
+ };
+ }
+ return { content: [{ type: 'text' as const, text: skillAsText(found) }] };
+ },
+ ) as unknown as ToolHandle,
+ });
+ }
+ }
+
// Companion to the Agent Skills Finder connector: its tools find and read
// a skill, this one files it as a *suggested* workspace skill. It lives
// here rather than in the adapter because it writes to this workspace,
@@ -1730,6 +1831,14 @@ export class McpEndpointController {
.string()
.max(80)
.describe('SPDX id from skills_repo_info (e.g. "MIT", "Apache-2.0"), or "unknown".'),
+ procedure: z
+ .string()
+ .max(SKILL_BODY_MAX)
+ .optional()
+ .describe(
+ 'Optional: the full procedure in markdown (steps, templates, output format), in your own words. ' +
+ `Served on demand by ${SKILL_GET_TOOL}, never pasted into the instructions.`,
+ ),
},
annotations: {
title: 'Save skill to workspace',
@@ -1745,6 +1854,7 @@ export class McpEndpointController {
instruction: string;
sourceUrl: string;
license: string;
+ procedure?: string;
}) => {
const saved = await this.kgSkills.create(orgId, {
title: args.title,
@@ -1753,6 +1863,7 @@ export class McpEndpointController {
mcpServerId: serverId,
status: 'pending',
source: 'finder',
+ body: args.procedure,
});
const result = {
saved: true,
@@ -1842,6 +1953,53 @@ export class McpEndpointController {
* kg_how_to_obtain one entity at a time. Same scope as that tool. The content
* is built when the resource is read, so it is never staler than the graph.
*/
+ /**
+ * Offers each skill the caller can see as an MCP prompt, so clients with a
+ * prompt picker (slash commands) can start one by hand. Built with the
+ * server like the resources; the text is read when the prompt is used.
+ */
+ private async registerSkillPrompts(
+ mcpServer: McpServer,
+ organizationId: string,
+ serverId: string,
+ visibleConnectorIds: string[],
+ ): Promise {
+ const skills = await this.kgSkills.listForCaller(organizationId, serverId, visibleConnectorIds);
+ const used = new Set();
+ for (const skill of skills) {
+ const name = uniquePromptName(skill.title, used);
+ try {
+ mcpServer.registerPrompt(
+ name,
+ { title: skill.title, description: skill.whenToUse || undefined },
+ async () => {
+ const found = await this.kgSkills.getForCaller(
+ organizationId,
+ serverId,
+ visibleConnectorIds,
+ skill.id,
+ );
+ return {
+ messages: [
+ {
+ role: 'user' as const,
+ content: {
+ type: 'text' as const,
+ text: found
+ ? `Follow this workspace skill for my next request.\n\n${skillAsText(found)}`
+ : 'This skill is no longer available.',
+ },
+ },
+ ],
+ };
+ },
+ );
+ } catch (err: any) {
+ this.logger.warn(`Skill prompt "${name}" not registered: ${err?.message ?? err}`);
+ }
+ }
+ }
+
private registerKgResource(mcpServer: McpServer, params: ToolSetParams): void {
const { kgEnabled, invocationContext } = params;
const orgId = invocationContext.organizationId;
@@ -1910,3 +2068,35 @@ export class McpEndpointController {
return stripEnvVarParams(schema, envVars);
}
}
+
+/** One skill as the text a model reads (skill_get, prompts). */
+function skillAsText(skill: {
+ id: string;
+ title: string;
+ whenToUse: string;
+ instruction: string;
+ body: string | null;
+ version: number;
+}): string {
+ const parts = [`# ${skill.title}`, `id: ${skill.id} · version ${skill.version}`];
+ if (skill.whenToUse) parts.push(`When to use: ${skill.whenToUse}`);
+ parts.push(`Rule: ${skill.instruction}`);
+ if (skill.body) parts.push(`## Procedure\n${skill.body}`);
+ return parts.join('\n\n');
+}
+
+/** A prompt name from a skill title: lower-case slug, unique on the server. */
+function uniquePromptName(title: string, used: Set): string {
+ const base =
+ title
+ .toLowerCase()
+ .normalize('NFKD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 48) || 'skill';
+ let name = base;
+ for (let n = 2; used.has(name); n++) name = `${base}-${n}`;
+ used.add(name);
+ return name;
+}
diff --git a/packages/backend/src/mcp-server/shared-endpoint.assistant.spec.ts b/packages/backend/src/mcp-server/shared-endpoint.assistant.spec.ts
index 5803c1560..0b54c43fc 100644
--- a/packages/backend/src/mcp-server/shared-endpoint.assistant.spec.ts
+++ b/packages/backend/src/mcp-server/shared-endpoint.assistant.spec.ts
@@ -36,7 +36,7 @@ function build(setup: unknown) {
resolve: jest.fn().mockResolvedValue(null),
clientRedirectUris: jest.fn(async (id?: string) => (id ? (REDIRECTS[id] ?? []) : [])),
} as any,
- { create: jest.fn() } as any,
+ { create: jest.fn(), hasVisible: jest.fn().mockResolvedValue(false) } as any,
{ get: () => setup } as any,
);
(controller as any).serveStateless = jest.fn(
diff --git a/packages/backend/src/mcp-server/shared-endpoint.controller.spec.ts b/packages/backend/src/mcp-server/shared-endpoint.controller.spec.ts
index 569c1fdad..4c4ecff06 100644
--- a/packages/backend/src/mcp-server/shared-endpoint.controller.spec.ts
+++ b/packages/backend/src/mcp-server/shared-endpoint.controller.spec.ts
@@ -98,7 +98,7 @@ function build(
resolve: jest.fn().mockResolvedValue(opts.grant ?? null),
clientRedirectUris: jest.fn(async (id?: string) => (id ? (REDIRECTS[id] ?? []) : [])),
} as any,
- { create: jest.fn() } as any,
+ { create: jest.fn(), hasVisible: jest.fn().mockResolvedValue(false) } as any,
);
// Capture the per-request server instead of serving HTTP, then talk to it
diff --git a/packages/backend/src/mcp-servers/mcp-servers.controller.ts b/packages/backend/src/mcp-servers/mcp-servers.controller.ts
index 5a6712f26..6b12d4e42 100644
--- a/packages/backend/src/mcp-servers/mcp-servers.controller.ts
+++ b/packages/backend/src/mcp-servers/mcp-servers.controller.ts
@@ -15,7 +15,8 @@ import {
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PaginationQueryDto } from '../common/pagination.dto';
import { AuthGuard } from '@nestjs/passport';
-import { IsString, IsOptional, IsBoolean, IsArray } from 'class-validator';
+import { IsString, IsOptional, IsBoolean, IsArray, IsIn } from 'class-validator';
+import { SKILLS_DELIVERY_MODES, type SkillsDelivery } from '../knowledge-graph/kg-skill.service';
import { McpServersService } from './mcp-servers.service';
import { LicenseGuardService } from '../license/license-guard.service';
import { PrismaService } from '../common/prisma.service';
@@ -73,6 +74,15 @@ class UpdateMcpServerDto {
@IsOptional()
@IsBoolean()
isActive?: boolean;
+
+ @ApiPropertyOptional({
+ enum: SKILLS_DELIVERY_MODES,
+ description:
+ 'How applied skills reach AI clients on connect: auto (full text while small, else an index for skill_get), inline (always the full text), index (always the index).',
+ })
+ @IsOptional()
+ @IsIn(SKILLS_DELIVERY_MODES as unknown as string[])
+ skillsDelivery?: SkillsDelivery;
}
class AssignConnectorsDto {
diff --git a/packages/backend/src/mcp-servers/mcp-servers.instructions.spec.ts b/packages/backend/src/mcp-servers/mcp-servers.instructions.spec.ts
index b23ddf44e..aee0c66b7 100644
--- a/packages/backend/src/mcp-servers/mcp-servers.instructions.spec.ts
+++ b/packages/backend/src/mcp-servers/mcp-servers.instructions.spec.ts
@@ -46,7 +46,7 @@ describe('McpServersService — caller-scoped instructions and resources', () =>
expect(text).toContain('Use the CRM.');
expect(text).not.toContain('Ledger rules.');
expect(text).not.toContain('Fibu');
- expect(kgSkills.activeSkillsText).toHaveBeenCalledWith('org-A', 'srv-1', ['c-crm']);
+ expect(kgSkills.activeSkillsText).toHaveBeenCalledWith('org-A', 'srv-1', ['c-crm'], 'auto');
});
it('scopes the assignment query to the visible ids AND the server organization', async () => {
@@ -64,7 +64,7 @@ describe('McpServersService — caller-scoped instructions and resources', () =>
const text = await service.getComposedInstructions('srv-1', []);
expect(text).toBe('SERVER-GUIDANCE\n\n## Workspace skills\n- server-wide');
expect(prisma.mcpServerConnector.findMany).not.toHaveBeenCalled();
- expect(kgSkills.activeSkillsText).toHaveBeenCalledWith('org-A', 'srv-1', []);
+ expect(kgSkills.activeSkillsText).toHaveBeenCalledWith('org-A', 'srv-1', [], 'auto');
});
it('getVisibleContent returns the visible connectors with instructions and their stored rows', async () => {
@@ -139,8 +139,8 @@ describe('McpServersService — shared guide skills per workspace', () => {
const text = await service.getSharedGuide({ connectorIds: ['c-a', 'c-b'], serverIds: ['srv-a'] });
expect(kgSkills.activeSkillsText).toHaveBeenCalledTimes(2);
- expect(kgSkills.activeSkillsText).toHaveBeenCalledWith('org-A', ['srv-a'], ['c-a']);
- expect(kgSkills.activeSkillsText).toHaveBeenCalledWith('org-B', [], ['c-b']);
+ expect(kgSkills.activeSkillsText).toHaveBeenCalledWith('org-A', ['srv-a'], ['c-a'], 'full');
+ expect(kgSkills.activeSkillsText).toHaveBeenCalledWith('org-B', [], ['c-b'], 'full');
expect(text).toContain('- org-A');
expect(text).toContain('- org-B');
});
diff --git a/packages/backend/src/mcp-servers/mcp-servers.service.ts b/packages/backend/src/mcp-servers/mcp-servers.service.ts
index eb21cbaee..338e89e3f 100644
--- a/packages/backend/src/mcp-servers/mcp-servers.service.ts
+++ b/packages/backend/src/mcp-servers/mcp-servers.service.ts
@@ -1,6 +1,10 @@
import { ConflictException, Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../common/prisma.service';
-import { KgSkillService } from '../knowledge-graph/kg-skill.service';
+import {
+ KgSkillService,
+ SKILLS_DELIVERY_MODES,
+ type SkillsDelivery,
+} from '../knowledge-graph/kg-skill.service';
import { McpSessionManager } from './mcp-session.manager';
/** What the per-server endpoint may serve as text to one caller. */
@@ -163,7 +167,7 @@ export class McpServersService {
);
}
- async update(id: string, data: { name?: string; slug?: string; description?: string; instructions?: string; isActive?: boolean }) {
+ async update(id: string, data: { name?: string; slug?: string; description?: string; instructions?: string; isActive?: boolean; skillsDelivery?: string }) {
return this.withSlugConflict(data.slug, () =>
this.prisma.mcpServerConfig.update({
where: { id },
@@ -390,10 +394,12 @@ export class McpServersService {
// skills with its own organization and only its own server/connector ids.
const orgIds = [...new Set(connectors.map((c) => c.organizationId))];
for (const orgId of orgIds) {
+ // No skill_get on the shared endpoint: procedures are returned in full.
const skillsText = await this.kgSkills.activeSkillsText(
orgId,
servers.filter((s) => s.organizationId === orgId).map((s) => s.id),
connectors.filter((c) => c.organizationId === orgId).map((c) => c.id),
+ 'full',
);
if (skillsText) parts.push(skillsText);
}
@@ -481,7 +487,7 @@ export class McpServersService {
serverId: string,
visibleConnectorIds: string[],
): Promise<{
- server: { instructions: string | null; organizationId: string } | null;
+ server: { instructions: string | null; organizationId: string; skillsDelivery?: string } | null;
serverConnectors: Array<{
connectorId: string;
connector: { id: string; name: string; instructions: string | null };
@@ -489,7 +495,7 @@ export class McpServersService {
}> {
const server = await this.prisma.mcpServerConfig.findUnique({
where: { id: serverId },
- select: { instructions: true, organizationId: true },
+ select: { instructions: true, organizationId: true, skillsDelivery: true },
});
const visible = [...new Set(visibleConnectorIds)];
if (!server || visible.length === 0) return { server, serverConnectors: [] };
@@ -511,7 +517,7 @@ export class McpServersService {
private async composeInstructions(
serverId: string,
- server: { instructions: string | null; organizationId: string } | null,
+ server: { instructions: string | null; organizationId: string; skillsDelivery?: string } | null,
serverConnectors: Array<{
connectorId: string;
connector: { name: string; instructions: string | null };
@@ -537,6 +543,7 @@ export class McpServersService {
server.organizationId,
serverId,
serverConnectors.map((sc) => sc.connectorId),
+ toDelivery(server.skillsDelivery),
)
: null;
if (skillsText) parts.push(skillsText);
@@ -589,3 +596,9 @@ export class McpServersService {
|| 'server';
}
}
+
+function toDelivery(value: string | undefined): SkillsDelivery {
+ return (SKILLS_DELIVERY_MODES as readonly string[]).includes(value ?? '')
+ ? (value as SkillsDelivery)
+ : 'auto';
+}
diff --git a/packages/frontend/src/app/knowledge-graph/skills/page.tsx b/packages/frontend/src/app/knowledge-graph/skills/page.tsx
index 80e039c6a..dacb13a10 100644
--- a/packages/frontend/src/app/knowledge-graph/skills/page.tsx
+++ b/packages/frontend/src/app/knowledge-graph/skills/page.tsx
@@ -8,6 +8,7 @@ import {
mcpServers as mcpServersApi,
connectors as connectorsApi,
type KgSkill,
+ type KgSkillVersion,
} from '@/lib/api';
import { AppShell } from '@/components/app-shell';
import { Button } from '@/components/ui/button';
@@ -212,9 +213,11 @@ export default function SkillsPage() {
{/* The header already carries the way back to the graph, so this stays
a description and nothing else. */}
- Rules inferred from the intents behind your tool calls, scoped to one connector or to a
- whole MCP server. Active skills are composed
- into that server's instructions automatically.
+ Rules and procedures for recurring tasks, scoped to one connector or to a whole MCP
+ server. Active skills reach that server's AI
+ clients on connect, as full text or as a list they open with{' '}
+ skill_get (set per server). Long procedures
+ are always opened on demand.
+ How this server's{' '}
+
+ AI Skills
+ {' '}
+ reach the AI client. A list keeps the instructions short; long procedures are always
+ opened on demand.
+