From 5c782bc693afb1e3ce172521515408f115e8ebe4 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 05:20:47 +0200 Subject: [PATCH 1/2] fix(sdk): send no numeric skill resource annotation and one compact skills line by default Each SKILL.md resource in resources/list carried priority: 0.8, and some clients fail the whole list on a numeric annotation. The new skillsConfig.resourceAnnotations ('no-priority' by default, 'full', 'off') keeps audience and lastModified and drops priority unless asked for. The initialize instructions listed every skill's name and description, and some clients copy them into each tool's description. The new default injectInstructions: 'compact' sends one line with the skill count the caller may see and where to read the skills; 'append', 'prepend' and 'replace' keep the full catalog. An unknown value for either option, or any invalid skillsConfig, makes @FrontMcp throw InvalidDecoratorMetadataError at boot. --- .../e2e/dts-consumer.e2e.spec.ts | 2 +- .../fixture/skills-must-compile.ts | 10 +- .../feature-flags-instructions.e2e.spec.ts | 33 +++-- apps/e2e/demo-e2e-feature-flags/src/main.ts | 2 + .../e2e/skills-only-mode.e2e.spec.ts | 16 ++ .../e2e/skills-resources.e2e.spec.ts | 42 +++++- apps/e2e/demo-e2e-skills/src/main.ts | 2 + .../common/decorators/front-mcp.decorator.ts | 7 + .../__tests__/skills-http.options.spec.ts | 39 +++++ .../common/types/options/skills-http/index.ts | 1 + .../types/options/skills-http/interfaces.ts | 38 ++++- .../types/options/skills-http/schema.ts | 3 +- .../src/skill/__tests__/skill-context.spec.ts | 2 +- .../skill/__tests__/skill-filter.flow.spec.ts | 3 +- .../skill-instructions.helper.spec.ts | 86 ++++++++++- .../src/skill/__tests__/skill-surface.spec.ts | 2 +- .../__tests__/sep-2640.per-skill.spec.ts | 137 ++++++++++++------ .../src/skill/sep-2640/sep-2640.per-skill.ts | 43 +++--- .../src/skill/skill-instructions.helper.ts | 69 ++++++--- libs/sdk/src/skill/skill-scope.helper.ts | 1 + .../transport.local.skill-hints.spec.ts | 4 +- .../inject-instructions.md | 9 +- .../references/configure-skills-http.md | 60 ++++++-- .../references/decorators-guide.md | 4 +- .../references/common-checklist.md | 2 +- libs/skills/catalog/skills-manifest.json | 5 +- .../src/__tests__/feature-flag.flows.spec.ts | 5 +- 27 files changed, 496 insertions(+), 131 deletions(-) diff --git a/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts b/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts index fcbfb5a3f..87eac5aef 100644 --- a/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts +++ b/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts @@ -129,7 +129,7 @@ describe('@frontmcp/sdk declarations in a strict consumer project', () => { expect(describeEach(inFixtureFile('host-must-compile.ts'))).toEqual([]); }); - it('types this.scope.skills.setExternalProvider() and skillsConfig.externalProvider', () => { + it('types this.scope.skills.setExternalProvider(), skillsConfig.externalProvider and its instructions and annotation policies', () => { expect(describeEach(inFixtureFile('skills-must-compile.ts'))).toEqual([]); }); diff --git a/apps/e2e/demo-e2e-dts-consumer/fixture/skills-must-compile.ts b/apps/e2e/demo-e2e-dts-consumer/fixture/skills-must-compile.ts index bf45feb51..1adef9988 100644 --- a/apps/e2e/demo-e2e-dts-consumer/fixture/skills-must-compile.ts +++ b/apps/e2e/demo-e2e-dts-consumer/fixture/skills-must-compile.ts @@ -5,6 +5,7 @@ import { FrontMcp, type ScopeEntry, type SkillContent, + type SkillsConfigOptions, type SkillSearchResult, } from '@frontmcp/sdk'; @@ -41,6 +42,13 @@ export function useCatalog(scope: ScopeEntry): void { info: { name: 'dts-skills', version: '1.0.0' }, apps: [], serve: false, - skillsConfig: { externalProvider: new CatalogSkillProvider({ mode: 'read-only' }) }, + skillsConfig: { + externalProvider: new CatalogSkillProvider({ mode: 'read-only' }), + injectInstructions: 'compact', + resourceAnnotations: 'full', + }, }) export class SkillsServer {} + +// @ts-expect-error resourceAnnotations accepts 'full' | 'no-priority' | 'off' only +export const unknownAnnotations: SkillsConfigOptions = { resourceAnnotations: 'audience' }; diff --git a/apps/e2e/demo-e2e-feature-flags/e2e/feature-flags-instructions.e2e.spec.ts b/apps/e2e/demo-e2e-feature-flags/e2e/feature-flags-instructions.e2e.spec.ts index 229fed190..3b31c62f5 100644 --- a/apps/e2e/demo-e2e-feature-flags/e2e/feature-flags-instructions.e2e.spec.ts +++ b/apps/e2e/demo-e2e-feature-flags/e2e/feature-flags-instructions.e2e.spec.ts @@ -1,17 +1,37 @@ /** - * E2E: the skill catalog in the `initialize` instructions runs the `skills:filter` flow for the - * caller, like `skills/list`, so a flag-disabled skill's name and description never reach a client (#603). + * E2E: the skills in the `initialize` instructions run the `skills:filter` flow for the caller, like + * `skills/list`, so a flag-disabled skill's name and description never reach a client (#603). * * - `enabled-workflow` (skill) sits behind `flag-for-skill`, which is on * - `hidden-workflow` (skill) sits behind `flag-for-hidden-skill`, which is off */ import { expect, test } from '@frontmcp/testing'; +const SERVER = 'apps/e2e/demo-e2e-feature-flags/src/main.ts'; + test.describe('Feature Flags in the initialize instructions E2E (#603)', () => { + test.use({ server: SERVER, project: 'demo-e2e-feature-flags', publicMode: true }); + + test('sends one compact line that counts only the flag-enabled skill', async ({ mcp }) => { + expect(mcp.instructions).toBe( + "This server has 1 skill: the `skill://index.json` resource lists each skill's `SKILL.md` URI.", + ); + }); + + test('leaves the same skill out of the SKILL.md entries in resources/list', async ({ mcp }) => { + const resources = await mcp.resources.list(); + + expect(resources).toContainResource('skill://enabled-workflow/SKILL.md'); + expect(resources).not.toContainResource('skill://hidden-workflow/SKILL.md'); + }); +}); + +test.describe("Feature Flags in the initialize instructions E2E with injectInstructions: 'append' (#603)", () => { test.use({ - server: 'apps/e2e/demo-e2e-feature-flags/src/main.ts', + server: SERVER, project: 'demo-e2e-feature-flags', publicMode: true, + env: { SKILLS_INJECT_INSTRUCTIONS: 'append' }, }); test('lists a flag-enabled skill in the skill catalog', async ({ mcp }) => { @@ -22,11 +42,4 @@ test.describe('Feature Flags in the initialize instructions E2E (#603)', () => { expect(mcp.instructions).not.toContain('hidden-workflow'); expect(mcp.instructions).not.toContain('(disabled)'); }); - - test('leaves the same skill out of the SKILL.md entries in resources/list', async ({ mcp }) => { - const resources = await mcp.resources.list(); - - expect(resources).toContainResource('skill://enabled-workflow/SKILL.md'); - expect(resources).not.toContainResource('skill://hidden-workflow/SKILL.md'); - }); }); diff --git a/apps/e2e/demo-e2e-feature-flags/src/main.ts b/apps/e2e/demo-e2e-feature-flags/src/main.ts index 8d415128e..d9aef5e06 100644 --- a/apps/e2e/demo-e2e-feature-flags/src/main.ts +++ b/apps/e2e/demo-e2e-feature-flags/src/main.ts @@ -4,6 +4,7 @@ import { FlaggedApp } from './apps/flagged'; import { GateDefaultsApp } from './apps/gate-defaults'; const port = parseInt(process.env['PORT'] ?? '3115', 10); +const injectInstructions = process.env['SKILLS_INJECT_INSTRUCTIONS'] === 'append' ? 'append' : undefined; @FrontMcp({ info: { name: 'Demo E2E Feature Flags', version: '0.1.0' }, @@ -18,5 +19,6 @@ const port = parseInt(process.env['PORT'] ?? '3115', 10); transport: { protocol: { json: true, legacy: true, strictSession: false }, }, + skillsConfig: { injectInstructions }, }) export default class Server {} diff --git a/apps/e2e/demo-e2e-skills/e2e/skills-only-mode.e2e.spec.ts b/apps/e2e/demo-e2e-skills/e2e/skills-only-mode.e2e.spec.ts index 817dfc79d..d745c7039 100644 --- a/apps/e2e/demo-e2e-skills/e2e/skills-only-mode.e2e.spec.ts +++ b/apps/e2e/demo-e2e-skills/e2e/skills-only-mode.e2e.spec.ts @@ -5,6 +5,7 @@ * - tools/list returns an empty array * - tools/call is answered as for an unknown tool (`Tool "" not found`) * - skills/search and skills/load work normally + * - the initialize instructions still point at the skill index * * This mode is useful for planner agents that: * - Fetch skills to create execution plans @@ -129,6 +130,21 @@ test.describe('MCP Skills-Only Mode E2E', () => { } }); + test('should point the client at the skill index in its instructions', async ({ server }) => { + const client = await server + .createClientBuilder() + .withTransport('streamable-http') + .withQueryParams({ mode: 'skills_only' }) + .buildAndConnect(); + + try { + expect(client.instructions).toMatch(/^This server has \d+ skills: the `skill:\/\/index\.json` resource/); + expect(client.instructions).not.toContain('**review-pr**'); + } finally { + await client.disconnect(); + } + }); + test('should refuse tool execution in skills-only mode', async ({ server }) => { const builder = server.createClientBuilder(); const client = await builder diff --git a/apps/e2e/demo-e2e-skills/e2e/skills-resources.e2e.spec.ts b/apps/e2e/demo-e2e-skills/e2e/skills-resources.e2e.spec.ts index 3b4878027..33094b04a 100644 --- a/apps/e2e/demo-e2e-skills/e2e/skills-resources.e2e.spec.ts +++ b/apps/e2e/demo-e2e-skills/e2e/skills-resources.e2e.spec.ts @@ -6,8 +6,9 @@ * - skill://{skillPath}/SKILL.md — raw SKILL.md (frontmatter + body) * - skill://{skillPath}/{filePath} — any file inside the skill directory * - Auto-complete for skill paths + * - The annotations of each SKILL.md resource in resources/list (`skillsConfig.resourceAnnotations`) */ -import { expect, test } from '@frontmcp/testing'; +import { expect, test, type Resource } from '@frontmcp/testing'; import type { JsonRpcRequest, JsonRpcResponse } from './helpers/skills-protocol'; @@ -66,6 +67,12 @@ interface SkillIndexDocument { skills: SkillIndexEntry[]; } +async function listSkillMdResources(mcp: { resources: { list(): Promise } }): Promise { + const skillMds = (await mcp.resources.list()).filter((resource) => resource.uri.endsWith('/SKILL.md')); + expect(skillMds.length).toBeGreaterThan(0); + return skillMds; +} + test.describe('SEP-2640 Skills Resources E2E', () => { test.use({ server: 'apps/e2e/demo-e2e-skills/src/main.ts', @@ -97,6 +104,24 @@ test.describe('SEP-2640 Skills Resources E2E', () => { const result = await mcp.resources.read(pointedAt ?? ''); expect(result).toBeSuccessful(); }); + + test('default to one line that counts the skills instead of listing them', async ({ mcp }) => { + const index = extractResourceJson(await mcp.resources.read('skill://index.json')); + const skillMdCount = index.skills.filter((entry) => entry.type === 'skill-md').length; + + expect(mcp.instructions).toBe( + `This server has ${skillMdCount} skills: the \`skill://index.json\` resource lists each skill's \`SKILL.md\` URI.`, + ); + }); + }); + + test.describe('SKILL.md annotations in resources/list', () => { + test('carry the assistant audience and no numeric annotation by default', async ({ mcp }) => { + for (const resource of await listSkillMdResources(mcp)) { + expect(resource.annotations?.audience).toEqual(['assistant']); + expect(Object.values(resource.annotations ?? {}).filter((value) => typeof value === 'number')).toEqual([]); + } + }); }); test.describe('skill://index.json', () => { @@ -263,3 +288,18 @@ test.describe('SEP-2640 Skills Resources E2E', () => { }); }); }); + +test.describe("SEP-2640 Skills Resources E2E with resourceAnnotations: 'full'", () => { + test.use({ + server: 'apps/e2e/demo-e2e-skills/src/main.ts', + project: 'demo-e2e-skills', + publicMode: true, + env: { SKILLS_RESOURCE_ANNOTATIONS: 'full' }, + }); + + test('bring back priority 0.8 on every SKILL.md resource', async ({ mcp }) => { + for (const resource of await listSkillMdResources(mcp)) { + expect(resource.annotations).toMatchObject({ audience: ['assistant'], priority: 0.8 }); + } + }); +}); diff --git a/apps/e2e/demo-e2e-skills/src/main.ts b/apps/e2e/demo-e2e-skills/src/main.ts index 46105da3c..207870878 100644 --- a/apps/e2e/demo-e2e-skills/src/main.ts +++ b/apps/e2e/demo-e2e-skills/src/main.ts @@ -9,6 +9,7 @@ const port = parseInt(process.env['PORT'] ?? '3107', 10); // the long-standing public-mode server (no authorities) so all existing skills // e2es behave exactly as before. const authoritiesMode = process.env['AUTHORITIES_MODE'] === '1'; +const resourceAnnotations = process.env['SKILLS_RESOURCE_ANNOTATIONS'] === 'full' ? 'full' : undefined; if (authoritiesMode) { const idpProviderUrl = process.env['IDP_PROVIDER_URL'] || 'https://sample-app.frontegg.com'; @@ -65,6 +66,7 @@ if (authoritiesMode) { enabled: true, auth: 'public', // Single auth config for all HTTP endpoints mcpResources: true, // Enable SEP-2640 skill:// MCP resource templates + resourceAnnotations, }, }) class Server {} diff --git a/libs/sdk/src/common/decorators/front-mcp.decorator.ts b/libs/sdk/src/common/decorators/front-mcp.decorator.ts index c3316cecd..5bc2fd226 100644 --- a/libs/sdk/src/common/decorators/front-mcp.decorator.ts +++ b/libs/sdk/src/common/decorators/front-mcp.decorator.ts @@ -74,6 +74,13 @@ export function FrontMcp(providedMetadata: FrontMcpMetadata): ClassDecorator { if (formatted.providers) { throw new InvalidDecoratorMetadataError('FrontMcp', 'providers', JSON.stringify(formatted.providers, null, 2)); } + if (formatted.skillsConfig) { + throw new InvalidDecoratorMetadataError( + 'FrontMcp', + 'skillsConfig', + JSON.stringify(formatted.skillsConfig, null, 2), + ); + } const loggingFormat = formatted['logging'] as Record | undefined; if (loggingFormat?.['transports']) { throw new InvalidDecoratorMetadataError( diff --git a/libs/sdk/src/common/types/options/__tests__/skills-http.options.spec.ts b/libs/sdk/src/common/types/options/__tests__/skills-http.options.spec.ts index 070c16bc3..ff2a95c8a 100644 --- a/libs/sdk/src/common/types/options/__tests__/skills-http.options.spec.ts +++ b/libs/sdk/src/common/types/options/__tests__/skills-http.options.spec.ts @@ -1,5 +1,9 @@ // common/types/options/__tests__/skills-http.options.spec.ts +import 'reflect-metadata'; + +import { InvalidDecoratorMetadataError } from '../../../../errors'; +import { FrontMcp } from '../../../decorators/front-mcp.decorator'; import { normalizeEndpointConfig, normalizeSkillsConfigOptions, @@ -98,6 +102,41 @@ describe('skillsConfigOptionsSchema', () => { const result = skillsConfigOptionsSchema.parse({}); expect(result.api).toBe(true); }); + + it("should apply default injectInstructions='compact'", () => { + const result = skillsConfigOptionsSchema.parse({}); + expect(result.injectInstructions).toBe('compact'); + }); + + it("should apply default resourceAnnotations='no-priority'", () => { + const result = skillsConfigOptionsSchema.parse({}); + expect(result.resourceAnnotations).toBe('no-priority'); + }); + }); + + describe('instructions and annotation policies', () => { + it.each(['off', 'compact', 'append', 'prepend', 'replace'])('should accept injectInstructions=%s', (policy) => { + expect(skillsConfigOptionsSchema.safeParse({ injectInstructions: policy }).success).toBe(true); + }); + + it.each(['full', 'no-priority', 'off'])('should accept resourceAnnotations=%s', (policy) => { + expect(skillsConfigOptionsSchema.safeParse({ resourceAnnotations: policy }).success).toBe(true); + }); + + it.each([ + ['injectInstructions', 'everything'], + ['resourceAnnotations', 'audience'], + ])('should refuse an unknown %s at boot', (option, value) => { + const boot = () => + FrontMcp({ + info: { name: 'invalid-skills-config', version: '1.0.0' }, + apps: [], + skillsConfig: { [option]: value }, + } as never)(class InvalidSkillsConfigServer {}); + + expect(boot).toThrow(InvalidDecoratorMetadataError); + expect(boot).toThrow(new RegExp(`skillsConfig[\\s\\S]*${option}`)); + }); }); describe('validation', () => { diff --git a/libs/sdk/src/common/types/options/skills-http/index.ts b/libs/sdk/src/common/types/options/skills-http/index.ts index 52a32ad9b..3e0b9cab0 100644 --- a/libs/sdk/src/common/types/options/skills-http/index.ts +++ b/libs/sdk/src/common/types/options/skills-http/index.ts @@ -5,6 +5,7 @@ export type { SkillsConfigOptions, SkillsConfigEndpointConfig, SkillsConfigAuthMode, + SkillResourceAnnotations, SkillsConfigAuditOptions, SkillsConfigJwtOptions, SkillsConfigCacheOptions, diff --git a/libs/sdk/src/common/types/options/skills-http/interfaces.ts b/libs/sdk/src/common/types/options/skills-http/interfaces.ts index 520f8ebeb..08ba86946 100644 --- a/libs/sdk/src/common/types/options/skills-http/interfaces.ts +++ b/libs/sdk/src/common/types/options/skills-http/interfaces.ts @@ -78,6 +78,14 @@ import type { ExternalSkillProviderBase } from '../../../../skill/providers/exte */ export type SkillsConfigAuthMode = 'inherit' | 'public' | 'api-key' | 'bearer'; +/** + * Which annotations each `skill:///SKILL.md` resource carries in `resources/list`. + * - 'no-priority': `audience: ['assistant']` and `lastModified` (when known), no numeric annotation + * - 'full': also `priority: 0.8`, the SEP-2640 recommendation + * - 'off': no annotations + */ +export type SkillResourceAnnotations = 'full' | 'no-priority' | 'off'; + /** * JWT validation configuration for bearer auth mode. */ @@ -287,6 +295,19 @@ export interface SkillsConfigOptions { */ mcpResources?: boolean; + /** + * Which annotations each `skill:///SKILL.md` resource carries in `resources/list`. + * + * - `'no-priority'` (default): `audience: ['assistant']`, plus `lastModified` for file-backed + * skills. No numeric annotation is sent: some clients fail the whole `resources/list` when an + * annotation is a number. + * - `'full'`: also `priority: 0.8`, the SEP-2640 recommendation (the default up to 1.10.15). + * - `'off'`: no annotations. + * + * @default 'no-priority' + */ + resourceAnnotations?: SkillResourceAnnotations; + /** * SEP-2640 §Discovery — opt-in inclusion of skill URIs in the server's * `instructions` field. When `true`, the transport adapter adds a @@ -363,16 +384,23 @@ export interface SkillsConfigOptions { * `instructions` (from `@FrontMcp({ instructions })`) and any framework * hints (e.g. channel reply-tool guidance). * - * - `'append'` (default): `instructions` is sent as-is, followed by a + * - `'compact'` (default): `instructions` and channel hints, then one line + * with the number of skills the caller may see and where to read them + * (`skill://index.json`, or the `skills/search` and `skills/load` + * methods when `mcpResources` is false). No line when the caller sees no + * skill. Some clients copy the instructions into every tool description, + * so the full catalog can cost many tokens per tool. + * - `'append'`: `instructions` is sent as-is, followed by a * `\n\n---\n\n` separator and the per-skill summary (`**Name**: …` - * blocks separated by `---`). Channel hints sit between them. + * blocks separated by `---`). Channel hints sit between them. The + * default up to 1.10.15. * - `'prepend'`: the skill catalog summary is sent first, then channel * hints, then the server `instructions`. Useful when bundle guidance * must appear in the model's prefix. * - `'replace'`: surface ONLY the server `instructions` field — the * skill catalog summary AND channel hints are dropped. Use when the * server config is the canonical prompt and skills should be - * discovered via `searchSkills` instead. **When `instructions` is + * discovered via `skills/search` instead. **When `instructions` is * empty/undefined this falls back to `'append'` semantics** so a * misconfigured server doesn't silently drop everything. * - `'off'`: never inject the bundle skill catalog into `initialize`. @@ -380,9 +408,9 @@ export interface SkillsConfigOptions { * the per-skill summary is suppressed. Lowest-cost option for very * large catalogs (catalog summaries can otherwise blow context). * - * @default 'append' + * @default 'compact' */ - injectInstructions?: 'off' | 'append' | 'prepend' | 'replace'; + injectInstructions?: 'off' | 'compact' | 'append' | 'prepend' | 'replace'; /** * Ranking function for skill semantic search (the in-memory vector index). diff --git a/libs/sdk/src/common/types/options/skills-http/schema.ts b/libs/sdk/src/common/types/options/skills-http/schema.ts index 228c6fb44..6a649cb30 100644 --- a/libs/sdk/src/common/types/options/skills-http/schema.ts +++ b/libs/sdk/src/common/types/options/skills-http/schema.ts @@ -116,10 +116,11 @@ export const skillsConfigOptionsSchema = z.object({ llmFullTxt: skillsConfigEndpointInputSchema.optional().default(true), api: skillsConfigEndpointInputSchema.optional().default(true), mcpResources: z.boolean().optional().default(true), + resourceAnnotations: z.enum(['full', 'no-priority', 'off']).optional().default('no-priority'), sep2640InInstructions: z.boolean().optional().default(false), cache: skillsConfigCacheOptionsSchema.optional(), audit: skillsConfigAuditOptionsSchema.optional(), - injectInstructions: z.enum(['off', 'append', 'prepend', 'replace']).optional().default('append'), + injectInstructions: z.enum(['off', 'compact', 'append', 'prepend', 'replace']).optional().default('compact'), scoring: z.enum(['cosine', 'bm25']).optional(), failOnInvalidSkills: z.boolean().optional(), externalProvider: z diff --git a/libs/sdk/src/skill/__tests__/skill-context.spec.ts b/libs/sdk/src/skill/__tests__/skill-context.spec.ts index 8bc823df7..b80067053 100644 --- a/libs/sdk/src/skill/__tests__/skill-context.spec.ts +++ b/libs/sdk/src/skill/__tests__/skill-context.spec.ts @@ -101,7 +101,7 @@ describe('the description an overridden build() returns, wherever skills are lis info: { name: 'skill-context-listings', version: '1.0.0' }, apps: [DeskApp], logging: { level: LogLevel.Off }, - skillsConfig: { enabled: true, sep2640InInstructions: true }, + skillsConfig: { enabled: true, sep2640InInstructions: true, injectInstructions: 'append' as const }, }; client = await connect(config); server = await createTestFetchServer(config); diff --git a/libs/sdk/src/skill/__tests__/skill-filter.flow.spec.ts b/libs/sdk/src/skill/__tests__/skill-filter.flow.spec.ts index fd773431f..19fc08518 100644 --- a/libs/sdk/src/skill/__tests__/skill-filter.flow.spec.ts +++ b/libs/sdk/src/skill/__tests__/skill-filter.flow.spec.ts @@ -63,7 +63,7 @@ const serverConfig = { info: { name: 'skills-filter-flow', version: '1.0.0' }, apps: [GuidesApp], logging: { level: LogLevel.Off }, - skillsConfig: { enabled: true }, + skillsConfig: { enabled: true, injectInstructions: 'append' as const }, }; function textOf(result: ReadResourceResult): string { @@ -326,6 +326,7 @@ describe('skills:filter flow', () => { apps: [RecordedGuidesApp], logging: { level: LogLevel.Off }, instructions: 'Guides server.', + skillsConfig: { injectInstructions: 'append' }, }); expect(await initializeInstructions(scope)).toBe( diff --git a/libs/sdk/src/skill/__tests__/skill-instructions.helper.spec.ts b/libs/sdk/src/skill/__tests__/skill-instructions.helper.spec.ts index 60ea45409..8b8c23ea2 100644 --- a/libs/sdk/src/skill/__tests__/skill-instructions.helper.spec.ts +++ b/libs/sdk/src/skill/__tests__/skill-instructions.helper.spec.ts @@ -1,8 +1,11 @@ +import type { SkillEntry } from '../../common'; import { buildChannelInstructions, buildSkillsCatalogSummary, + composeCallerInstructions, composeInitializeInstructions, type InjectInstructionsPolicy, + type InstructionsScope, } from '../skill-instructions.helper'; import type { SkillRegistryInterface } from '../skill.registry'; @@ -206,7 +209,7 @@ describe('skill-instructions.helper', () => { const registry = makeRegistry([{ metadata: { name: 'demo', description: 'A demo skill' } }]); it('forwards mcpResources so a server without skill:// resources never advertises them', () => { - const out = composeInitializeInstructions({ skillRegistry: registry, mcpResources: false }); + const out = composeInitializeInstructions({ skillRegistry: registry, mcpResources: false, policy: 'append' }); expect(out).toContain('- **demo**: A demo skill'); expect(out).toContain('skills/load'); expect(out).not.toContain('skill://'); @@ -268,11 +271,12 @@ describe('skill-instructions.helper', () => { expect(out).not.toContain('demo'); }); - it("'append' (default) joins user, channel, then skill catalog", () => { + it("'append' joins user, channel, then skill catalog", () => { const out = composeInitializeInstructions({ userInstructions: 'User.', channelInstructions: 'Channel.', skillRegistry: registry, + policy: 'append', }); const userIdx = out.indexOf('User.'); const channelIdx = out.indexOf('Channel.'); @@ -300,6 +304,7 @@ describe('skill-instructions.helper', () => { userInstructions: '', channelInstructions: '', skillRegistry: registry, + policy: 'append', }); expect(out).not.toMatch(/---\n\n---/); expect(out).toContain('Available skills'); @@ -314,18 +319,18 @@ describe('skill-instructions.helper', () => { const { registry: dyn, add } = makeMutableRegistry([ { metadata: { name: 'first_skill', description: 'present at boot' } }, ]); - const before = composeInitializeInstructions({ skillRegistry: dyn }); + const before = composeInitializeInstructions({ skillRegistry: dyn, policy: 'append' }); expect(before).toContain('first_skill'); expect(before).not.toContain('second_skill'); add({ metadata: { name: 'second_skill', description: 'registered later' } }); - const after = composeInitializeInstructions({ skillRegistry: dyn }); + const after = composeInitializeInstructions({ skillRegistry: dyn, policy: 'append' }); expect(after).toContain('first_skill'); expect(after).toContain('second_skill'); }); - it.each(['off', 'append', 'prepend', 'replace'] satisfies InjectInstructionsPolicy[])( + it.each(['off', 'compact', 'append', 'prepend', 'replace'] satisfies InjectInstructionsPolicy[])( "policy '%s' never inserts trailing separator", (policy) => { const out = composeInitializeInstructions({ @@ -339,6 +344,77 @@ describe('skill-instructions.helper', () => { ); }); + describe("'compact' policy", () => { + const resourceLine = (skills: string) => + `This server has ${skills}: the \`skill://index.json\` resource lists each skill's \`SKILL.md\` URI.`; + const twoSkills = makeRegistry([ + { metadata: { name: 'review_pr', description: 'Review a pull request' } }, + { metadata: { name: 'deploy', description: 'Deploy to production' } }, + ]); + + it('is the default: one line with the skill count, naming no skill', () => { + expect(composeInitializeInstructions({ skillRegistry: twoSkills })).toBe(resourceLine('2 skills')); + }); + + it('counts a single skill in the singular', () => { + const registry = makeRegistry([{ metadata: { name: 'demo', description: 'A demo skill' } }]); + expect(composeInitializeInstructions({ skillRegistry: registry, policy: 'compact' })).toBe( + resourceLine('1 skill'), + ); + }); + + it('points at the skills/* methods when mcpResources is disabled', () => { + const out = composeInitializeInstructions({ skillRegistry: twoSkills, mcpResources: false }); + expect(out).toBe( + 'This server has 2 skills: find one with the `skills/search` request and load it with `skills/load`.', + ); + }); + + it('follows the server instructions and channel hints', () => { + const out = composeInitializeInstructions({ + userInstructions: 'User.', + channelInstructions: 'Channel.', + skillRegistry: twoSkills, + }); + expect(out).toBe(`User.\n\n---\n\nChannel.\n\n---\n\n${resourceLine('2 skills')}`); + }); + + it('adds no line when no skill is MCP-visible', () => { + const httpOnly = makeRegistry([ + { metadata: { name: 'http_only', description: 'HTTP only', visibility: 'http' } }, + ]); + expect(composeInitializeInstructions({ userInstructions: 'User.', skillRegistry: httpOnly })).toBe('User.'); + }); + + it('adds no line without a skill registry', () => { + expect(composeInitializeInstructions({ userInstructions: 'User.' })).toBe('User.'); + }); + + it('counts only the skills the skills:filter flow keeps for the caller', async () => { + const scope = { + metadata: {}, + skills: twoSkills, + logger: { warn: jest.fn() }, + runFlowForOutput: async (_flow: string, input: { skills: SkillEntry[] }) => ({ + skills: input.skills.filter((skill) => skill.metadata.name === 'deploy'), + }), + } as unknown as InstructionsScope; + + expect(await composeCallerInstructions(scope)).toBe(resourceLine('1 skill')); + }); + + it('adds no line when the caller may see no skill', async () => { + const scope = { + metadata: { instructions: 'Server prompt.' }, + skills: twoSkills, + logger: { warn: jest.fn() }, + runFlowForOutput: async () => ({ skills: [] }), + } as unknown as InstructionsScope; + + expect(await composeCallerInstructions(scope)).toBe('Server prompt.'); + }); + }); + describe('buildChannelInstructions', () => { it('returns empty string when channels are absent', () => { expect(buildChannelInstructions(undefined)).toBe(''); diff --git a/libs/sdk/src/skill/__tests__/skill-surface.spec.ts b/libs/sdk/src/skill/__tests__/skill-surface.spec.ts index a33e1cd7d..75b19752e 100644 --- a/libs/sdk/src/skill/__tests__/skill-surface.spec.ts +++ b/libs/sdk/src/skill/__tests__/skill-surface.spec.ts @@ -35,7 +35,7 @@ const serverConfig = { info: { name: 'skills-surface', version: '1.0.0' }, apps: [GuidesApp], logging: { level: LogLevel.Off }, - skillsConfig: { enabled: true }, + skillsConfig: { enabled: true, injectInstructions: 'append' as const }, }; function textOf(result: ReadResourceResult): string { diff --git a/libs/sdk/src/skill/sep-2640/__tests__/sep-2640.per-skill.spec.ts b/libs/sdk/src/skill/sep-2640/__tests__/sep-2640.per-skill.spec.ts index 4f6058274..d2b7ddc66 100644 --- a/libs/sdk/src/skill/sep-2640/__tests__/sep-2640.per-skill.spec.ts +++ b/libs/sdk/src/skill/sep-2640/__tests__/sep-2640.per-skill.spec.ts @@ -3,8 +3,8 @@ * * SEP-2640 §Resource Metadata says each `skill:///SKILL.md` * resource SHOULD carry frontmatter-derived `name`/`description` plus - * `audience`/`priority` annotations. The per-skill registration emits - * those — verified here. + * annotations. The per-skill registration emits those, per + * `skillsConfig.resourceAnnotations` — verified here. */ import 'reflect-metadata'; @@ -46,19 +46,34 @@ describe('buildPerSkillResourceRecord', () => { expect(rec.metadata.mimeType).toBe(SKILL_MD_MIME_TYPE); }); - it('attaches audience: ["assistant"] and priority 0.8 annotations', () => { + it('attaches audience: ["assistant"] and no numeric priority by default', () => { const skill = makeSkill({ name: 'a', description: 'A' }); const rec = buildPerSkillResourceRecord(fakeScope, skill); - expect(rec.metadata.annotations).toEqual({ - audience: ['assistant'], - priority: SKILL_MD_PRIORITY, + expect(rec.metadata.annotations).toEqual({ audience: ['assistant'] }); + }); + + it("attaches priority 0.8 under resourceAnnotations: 'full'", () => { + const skill = makeSkill({ name: 'a', description: 'A' }); + const rec = buildPerSkillResourceRecord(fakeScope, skill, { resourceAnnotations: 'full' }); + + expect(rec.metadata.annotations).toEqual({ audience: ['assistant'], priority: SKILL_MD_PRIORITY }); + }); + + it("attaches no annotations under resourceAnnotations: 'off'", () => { + const skill = makeSkill({ name: 'a', description: 'A' }); + const rec = buildPerSkillResourceRecord(fakeScope, skill, { + resourceAnnotations: 'off', + lastModified: '2026-05-05T12:00:00Z', }); + + expect('annotations' in rec.metadata).toBe(false); }); - it('includes lastModified when supplied', () => { + it.each(['no-priority', 'full'] as const)('includes lastModified when supplied under %s', (resourceAnnotations) => { const skill = makeSkill({ name: 'a', description: 'A' }); const rec = buildPerSkillResourceRecord(fakeScope, skill, { + resourceAnnotations, lastModified: '2026-05-05T12:00:00Z', }); expect(rec.metadata.annotations?.lastModified).toBe('2026-05-05T12:00:00Z'); @@ -130,43 +145,81 @@ describe('buildPerSkillResourceRecord', () => { }); }); -describe('registerPerSkillResources when a skill is replaced at the same path (#606)', () => { - function setup(initial: SkillEntry) { - let served: SkillEntry[] = [initial]; - const listeners: Array<() => void> = []; - const records: ResourceFunctionRecord[] = []; - const disposers: Array<() => void> = []; - const scope = { - onDispose: (callback: () => void) => disposers.push(callback), - skills: { - hasAny: () => true, - getSkills: () => served, - subscribe: (_options: unknown, listener: () => void) => { - listeners.push(listener); - return () => listeners.splice(listeners.indexOf(listener), 1); - }, +type RegisterOptions = Pick< + Parameters[0], + 'resourceAnnotations' | 'resolveLastModified' +>; + +function setup(initial: SkillEntry, options: RegisterOptions = {}) { + let served: SkillEntry[] = [initial]; + const listeners: Array<() => void> = []; + const records: ResourceFunctionRecord[] = []; + const disposers: Array<() => void> = []; + const scope = { + onDispose: (callback: () => void) => disposers.push(callback), + skills: { + hasAny: () => true, + getSkills: () => served, + subscribe: (_options: unknown, listener: () => void) => { + listeners.push(listener); + return () => listeners.splice(listeners.indexOf(listener), 1); }, - } as unknown as ScopeEntry; - const unregistered: unknown[] = []; - const resourceRegistry = { - registerDynamicResource: (record: ResourceFunctionRecord) => records.push(record), - unregisterResourceInstance: (token: unknown) => unregistered.push(token), - } as unknown as ResourceRegistry; - const logger = { verbose: jest.fn(), warn: jest.fn() } as unknown as FrontMcpLogger; - - return { - register: () => registerPerSkillResources({ scope, skillRegistry: scope.skills, resourceRegistry, logger }), - metadata: () => (records[0]?.metadata ?? {}) as unknown as Record, - records, - unregistered, - dispose: () => disposers.forEach((callback) => callback()), - replaceWith: (skill?: SkillEntry) => { - served = skill ? [skill] : []; - for (const listener of [...listeners]) listener(); - }, - }; - } + }, + } as unknown as ScopeEntry; + const unregistered: unknown[] = []; + const resourceRegistry = { + registerDynamicResource: (record: ResourceFunctionRecord) => records.push(record), + unregisterResourceInstance: (token: unknown) => unregistered.push(token), + } as unknown as ResourceRegistry; + const logger = { verbose: jest.fn(), warn: jest.fn() } as unknown as FrontMcpLogger; + + return { + register: () => + registerPerSkillResources({ scope, skillRegistry: scope.skills, resourceRegistry, logger, ...options }), + metadata: () => (records[0]?.metadata ?? {}) as unknown as Record, + records, + unregistered, + dispose: () => disposers.forEach((callback) => callback()), + replaceWith: (skill?: SkillEntry) => { + served = skill ? [skill] : []; + for (const listener of [...listeners]) listener(); + }, + }; +} +describe('registerPerSkillResources annotations', () => { + const skill = makeSkill({ name: 'report', description: 'Open report' }); + const lastModified = '2026-05-05T12:00:00Z'; + + it('adds lastModified next to the audience by default', async () => { + const harness = setup(skill, { resolveLastModified: async () => lastModified }); + await harness.register(); + + expect(harness.metadata()['annotations']).toEqual({ audience: ['assistant'], lastModified }); + }); + + it("brings back priority 0.8 under resourceAnnotations: 'full'", async () => { + const harness = setup(skill, { resourceAnnotations: 'full', resolveLastModified: async () => lastModified }); + await harness.register(); + + expect(harness.metadata()['annotations']).toEqual({ + audience: ['assistant'], + priority: SKILL_MD_PRIORITY, + lastModified, + }); + }); + + it("neither annotates nor resolves lastModified under resourceAnnotations: 'off'", async () => { + const resolveLastModified = jest.fn(async () => lastModified); + const harness = setup(skill, { resourceAnnotations: 'off', resolveLastModified }); + await harness.register(); + + expect('annotations' in harness.metadata()).toBe(false); + expect(resolveLastModified).not.toHaveBeenCalled(); + }); +}); + +describe('registerPerSkillResources when a skill is replaced at the same path (#606)', () => { const plain = makeSkill({ name: 'report', description: 'Open report' }); const gated = makeSkill({ name: 'report', diff --git a/libs/sdk/src/skill/sep-2640/sep-2640.per-skill.ts b/libs/sdk/src/skill/sep-2640/sep-2640.per-skill.ts index 09041f3f7..43320e35f 100644 --- a/libs/sdk/src/skill/sep-2640/sep-2640.per-skill.ts +++ b/libs/sdk/src/skill/sep-2640/sep-2640.per-skill.ts @@ -13,7 +13,7 @@ * To satisfy that, we additionally register one **concrete** `Resource` per * MCP-visible skill, at scope startup and whenever skills are added or removed. * These show up in `resources/list` with frontmatter-derived metadata plus - * `audience`/`priority` annotations. + * the annotations `skillsConfig.resourceAnnotations` selects. * * When the skill served at a path is replaced, the resource takes the new * skill's description and policy metadata, so `resources/list` gates it by @@ -30,6 +30,7 @@ import { type ResourceMetadata, type ScopeEntry, type SkillEntry, + type SkillResourceAnnotations, } from '../../common'; import { ResourceKind } from '../../common/records/resource.record'; import type ResourceRegistry from '../../resource/resource.registry'; @@ -63,9 +64,9 @@ function skillExtensionMetadata(skill: SkillEntry): Record { * - `name` = skill's frontmatter name * - `description` = skill's frontmatter description (what a `build()` override returns, once loaded) * - `mimeType` = `text/markdown` - * - `annotations.audience` = `["assistant"]` - * - `annotations.priority` = 0.8 (the SEP-recommended SKILL.md priority) - * - `annotations.lastModified` (when supplied — populated for file-backed skills) + * - `annotations.audience` = `["assistant"]`, unless `resourceAnnotations` is `'off'` + * - `annotations.priority` = 0.8 (the SEP-recommended SKILL.md priority), only when `resourceAnnotations` is `'full'` + * - `annotations.lastModified` (when supplied — populated for file-backed skills), unless `resourceAnnotations` is `'off'` * - `_meta.io.modelcontextprotocol.skills/path` = the skill's `` * * The function is bound to the scope so `resources/read` invocation @@ -74,19 +75,12 @@ function skillExtensionMetadata(skill: SkillEntry): Record { export function buildPerSkillResourceRecord( scope: ScopeEntry, skill: SkillEntry, - opts: { lastModified?: string } = {}, + opts: { lastModified?: string; resourceAnnotations?: SkillResourceAnnotations } = {}, ): ResourceFunctionRecord { const skillPathSegments = skill.getSkillPathSegments(); const skillPath = skillPathSegments.join('/'); const uri = buildSkillUri(skillPathSegments, 'SKILL.md'); - - const annotations: ResourceMetadata['annotations'] = { - audience: ['assistant'], - priority: SKILL_MD_PRIORITY, - }; - if (opts.lastModified) { - annotations.lastModified = opts.lastModified; - } + const annotations = skillMdAnnotations(opts.resourceAnnotations ?? 'no-priority', opts.lastModified); const meta: Record = { [`${SEP_2640_META_NAMESPACE}path`]: skillPath, @@ -98,7 +92,7 @@ export function buildPerSkillResourceRecord( name: skill.metadata.name, description: skill.getDescription(), mimeType: SKILL_MD_MIME_TYPE, - annotations, + ...(annotations && { annotations }), _meta: meta, // The skill's own `availableWhen`, so `resources/list` and `resources/read` offer its SKILL.md // on the surfaces that offer the skill, and answer others as for an unknown resource. @@ -129,10 +123,22 @@ export function buildPerSkillResourceRecord( }; } +function skillMdAnnotations( + policy: SkillResourceAnnotations, + lastModified: string | undefined, +): ResourceMetadata['annotations'] { + if (policy === 'off') return undefined; + return { + audience: ['assistant'], + ...(policy === 'full' && { priority: SKILL_MD_PRIORITY }), + ...(lastModified && { lastModified }), + }; +} + /** * Register one concrete `Resource` per MCP-visible skill so * `resources/list` returns SEP-conformant entries (frontmatter-derived - * `name`/`description`, audience/priority annotations). + * `name`/`description`, and the annotations `resourceAnnotations` selects). * * This is in addition to — not a replacement for — the SKILL.md template * (`Sep2640SkillMdResource`). Hosts may discover skills either via @@ -147,13 +153,14 @@ export async function registerPerSkillResources(options: { resourceRegistry: ResourceRegistry; logger: FrontMcpLogger; resolveLastModified?: (skill: SkillEntry) => Promise; + resourceAnnotations?: SkillResourceAnnotations; }): Promise { - const { scope, skillRegistry, resourceRegistry, logger, resolveLastModified } = options; + const { scope, skillRegistry, resourceRegistry, logger, resolveLastModified, resourceAnnotations } = options; const registered = new Map(); const register = (skill: SkillEntry): Promise => { try { - const record = buildPerSkillResourceRecord(scope, skill); + const record = buildPerSkillResourceRecord(scope, skill, { resourceAnnotations }); // The resource registry's dynamic-register path accepts a normalised // record directly. resourceRegistry.registerDynamicResource(record); @@ -163,7 +170,7 @@ export async function registerPerSkillResources(options: { extensionKeys: Object.keys(skillExtensionMetadata(skill)), }); logger.verbose(`Registered SEP-2640 per-skill resource: ${record.metadata.uri}`); - return resolveLastModified + return resolveLastModified && record.metadata.annotations ? addLastModified(record.metadata, skill, resolveLastModified, logger) : Promise.resolve(); } catch (err) { diff --git a/libs/sdk/src/skill/skill-instructions.helper.ts b/libs/sdk/src/skill/skill-instructions.helper.ts index 5af567f75..e1c8ed60c 100644 --- a/libs/sdk/src/skill/skill-instructions.helper.ts +++ b/libs/sdk/src/skill/skill-instructions.helper.ts @@ -6,11 +6,13 @@ * - The `initialize` response's `instructions` field is server-side prompt * text that clients usually inject verbatim into the model context. Pushing * every skill's full SKILL.md body would balloon every initialize response - * and burn token budget on tool calls. Instead, we ship a bounded catalog - * summary (`**name**: description`) plus a pointer to where the full content - * is served: the SEP-2640 `skill://index.json` resource, or the - * `skills/search` and `skills/load` MCP extension methods when - * `skillsConfig.mcpResources` is false and no `skill://` resource exists. + * and burn token budget on tool calls. Instead, the default `'compact'` + * policy ships one line with the skill count, and `'append'`, `'prepend'` + * and `'replace'` a bounded catalog summary (`**name**: description`). Both + * point to where the full content is served: the SEP-2640 + * `skill://index.json` resource, or the `skills/search` and `skills/load` + * MCP extension methods when `skillsConfig.mcpResources` is false and no + * `skill://` resource exists. * - Resolution is sync — the registry already holds metadata at boot. The * per-skill `instructions` markdown body stays lazy. * - Off / replace policies short-circuit before we touch the registry, so a @@ -21,8 +23,9 @@ * after the server boots are reflected in subsequent reconnects without a * restart. * - Transports compose it per caller with {@link composeCallerInstructions}: - * the catalog and the SEP-2640 hints only name skills the skill authorities - * and the `skills:filter` flow let that caller see, like `skills/list`. + * the compact line, the catalog and the SEP-2640 hints only count or name + * skills the skill authorities and the `skills:filter` flow let that caller + * see, like `skills/list`. */ import type { ScopeEntry, SkillEntry } from '../common'; @@ -33,9 +36,9 @@ import { filterSkillsByAuthorities } from './skill-authorities.helper'; import { filterServableSkills } from './skill-filter.helper'; import type { SkillRegistryInterface } from './skill.registry'; -export type InjectInstructionsPolicy = 'off' | 'append' | 'prepend' | 'replace'; +export type InjectInstructionsPolicy = 'off' | 'compact' | 'append' | 'prepend' | 'replace'; -const DEFAULT_POLICY: InjectInstructionsPolicy = 'append'; +const DEFAULT_POLICY: InjectInstructionsPolicy = 'compact'; /** * Hard ceiling on the catalog block. Keeps the initialize payload bounded * regardless of how many skills are registered. ~16 KB fits comfortably in a @@ -77,18 +80,29 @@ export interface SkillsCatalogSummaryOptions { interface CatalogPointers { header: string; truncationHint: string; + compact: string; } const RESOURCE_POINTERS: CatalogPointers = { header: `Available skills (read the \`${SKILL_INDEX_URI}\` resource for each skill's \`SKILL.md\` URI):`, truncationHint: `read \`${SKILL_INDEX_URI}\` to browse`, + compact: `the \`${SKILL_INDEX_URI}\` resource lists each skill's \`SKILL.md\` URI.`, }; const METHOD_POINTERS: CatalogPointers = { header: 'Available skills (load full content with the `skills/load` request, or find others with `skills/search`):', truncationHint: 'use `skills/search` to find the rest', + compact: 'find one with the `skills/search` request and load it with `skills/load`.', }; +/** The skills the instructions count or list; `visibility: 'mcp'` matches both `'mcp'` and `'both'` (see `getSkills`). */ +function listedSkills( + skillRegistry: SkillRegistryInterface | undefined, + options: SkillsCatalogSummaryOptions, +): readonly SkillEntry[] { + return options.skills ?? skillRegistry?.getSkills({ visibility: 'mcp' }) ?? []; +} + /** * Sanitize a skill description so it can be embedded as a single bullet in a * markdown catalog without breaking out of its line context. @@ -169,8 +183,7 @@ export function buildSkillsCatalogSummary( skillRegistry: SkillRegistryInterface | undefined, options: SkillsCatalogSummaryOptions = {}, ): string { - // `visibility: 'mcp'` matches both `'mcp'` and `'both'` (see `getSkills`). - const skills = options.skills ?? skillRegistry?.getSkills({ visibility: 'mcp' }) ?? []; + const skills = listedSkills(skillRegistry, options); if (skills.length === 0) return ''; const pointers = options.mcpResources === false ? METHOD_POINTERS : RESOURCE_POINTERS; @@ -205,6 +218,19 @@ export function buildSkillsCatalogSummary( return lines.join('\n'); } +/** + * The `'compact'` line: how many skills there are and where to read them. Empty when there are none. + */ +function buildCompactSkillsLine( + skillRegistry: SkillRegistryInterface | undefined, + options: SkillsCatalogSummaryOptions, +): string { + const count = listedSkills(skillRegistry, options).length; + if (count === 0) return ''; + const pointers = options.mcpResources === false ? METHOD_POINTERS : RESOURCE_POINTERS; + return `This server has ${count === 1 ? '1 skill' : `${count} skills`}: ${pointers.compact}`; +} + /** * Compose the final `instructions` string for the MCP `initialize` response * by merging server instructions, channel hints, and the skill catalog @@ -215,7 +241,8 @@ export function buildSkillsCatalogSummary( * * Policy semantics: * - `'off'`: skips the skill catalog; user + channel hints are still emitted. - * - `'append'` (default): user + channel + catalog, in that order. + * - `'compact'` (default): user + channel + one line counting the skills, no line without skills. + * - `'append'`: user + channel + catalog, in that order. * - `'prepend'`: catalog + channel + user. * - `'replace'`: only the user-supplied instructions are emitted. **Falls * back to `'append'` semantics when `userInstructions` is empty/undefined** @@ -242,14 +269,16 @@ export function composeInitializeInstructions(options: ComposeOptions): string { return joinSections([user, channel]); } - const catalog = buildSkillsCatalogSummary(options.skillRegistry, { - mcpResources: options.mcpResources, - skills: options.skills, - }); + const summaryOptions: SkillsCatalogSummaryOptions = { mcpResources: options.mcpResources, skills: options.skills }; + if (policy === 'compact') { + return joinSections([user, channel, buildCompactSkillsLine(options.skillRegistry, summaryOptions)]); + } + + const catalog = buildSkillsCatalogSummary(options.skillRegistry, summaryOptions); if (policy === 'prepend') { return joinSections([catalog, channel, user]); } - // 'append' (default and 'replace'-with-empty-user fallback) + // 'append' (and the 'replace'-with-empty-user fallback) return joinSections([user, channel, catalog]); } @@ -299,9 +328,9 @@ export interface CallerInstructionsOptions { /** * Compose the `initialize` instructions for one caller: server instructions, channel hints and the - * skill catalog (plus the SEP-2640 hints when asked), per `skillsConfig.injectInstructions`. + * compact skills line or skill catalog (plus the SEP-2640 hints when asked), per `skillsConfig.injectInstructions`. * - * The catalog and hints only name the MCP-visible skills the skill authorities and the hookable + * The line, catalog and hints only count or name the MCP-visible skills the skill authorities and the hookable * `skills:filter` flow let this caller see, the same skills `skills/list` returns. The filter only * runs when the instructions would list a skill. If it fails, the skills are left out rather than * failing the handshake. With nothing filtering, the result equals {@link composeInitializeInstructions}. @@ -366,7 +395,7 @@ function skillUriHintsEnabled(scope: InstructionsScope): boolean { return !(skillsConfig.injectInstructions === 'replace' && serverInstructions.length > 0); } -/** Whether {@link composeInitializeInstructions} emits the skill catalog under these options. */ +/** Whether {@link composeInitializeInstructions} counts or lists skills under these options. */ function includesSkillCatalog(options: ComposeOptions): boolean { const policy = options.policy ?? DEFAULT_POLICY; if (policy === 'off') return false; diff --git a/libs/sdk/src/skill/skill-scope.helper.ts b/libs/sdk/src/skill/skill-scope.helper.ts index e1c16ef06..83fe5c5e2 100644 --- a/libs/sdk/src/skill/skill-scope.helper.ts +++ b/libs/sdk/src/skill/skill-scope.helper.ts @@ -146,6 +146,7 @@ export async function registerSkillCapabilities(options: SkillScopeRegistrationO resourceRegistry, logger, resolveLastModified: resolveLastModifiedForSkill, + resourceAnnotations: skillsConfig?.resourceAnnotations, }); } else { logger.verbose('SEP-2640 skill:// resources disabled via skillsConfig.mcpResources=false'); diff --git a/libs/sdk/src/transport/adapters/__tests__/transport.local.skill-hints.spec.ts b/libs/sdk/src/transport/adapters/__tests__/transport.local.skill-hints.spec.ts index 3a2f9d72a..7403ae52f 100644 --- a/libs/sdk/src/transport/adapters/__tests__/transport.local.skill-hints.spec.ts +++ b/libs/sdk/src/transport/adapters/__tests__/transport.local.skill-hints.spec.ts @@ -80,7 +80,9 @@ describe('initialize instructions: SEP-2640 skill URI hints', () => { }); it('sends no hints unless the transport asks for them', async () => { - const instructions = await composeCallerInstructions(makeScope({ sep2640InInstructions: true })); + const instructions = await composeCallerInstructions( + makeScope({ sep2640InInstructions: true, injectInstructions: 'append' }), + ); expect(instructions).toContain('**deploy**: Deploy to production'); expect(instructions).not.toContain(DEPLOY_HINT); diff --git a/libs/skills/catalog/frontmcp-config/examples/configure-skills-http/inject-instructions.md b/libs/skills/catalog/frontmcp-config/examples/configure-skills-http/inject-instructions.md index 4a9ec1e80..34dc861c8 100644 --- a/libs/skills/catalog/frontmcp-config/examples/configure-skills-http/inject-instructions.md +++ b/libs/skills/catalog/frontmcp-config/examples/configure-skills-http/inject-instructions.md @@ -2,11 +2,12 @@ name: inject-instructions reference: configure-skills-http level: basic -description: Set a server-level instructions string and append the skill catalog summary on every initialize response. +description: Set a server-level instructions string and append the full skill catalog summary on every initialize response. tags: [config, skills, instructions, injection, initialize] features: - 'Top-level `instructions` on `@FrontMcp` exposes a global system prompt to MCP clients' - "`skillsConfig.injectInstructions: 'append'` adds the skill catalog summary after the user prompt" + - "The default, `'compact'`, adds one line with the skill count and the `skill://index.json` pointer instead" - 'Dynamic skills are picked up because the composer runs on every initialize request' - 'The catalog only names skills the initializing caller may see (skill authorities and the `skills:filter` flow, e.g. feature flags)' - 'Catalog summary is bounded at 16 KB with a truncation footer pointing at skill://index.json' @@ -14,7 +15,7 @@ features: # Inject Instructions on Initialize -Set a server-level instructions string and append the skill catalog summary on every initialize response. +Set a server-level instructions string and append the full skill catalog summary on every initialize response. ## Code @@ -37,7 +38,8 @@ import { MainApp } from './main.app'; skillsConfig: { enabled: true, mcpResources: true, - // 'append' (default) — the skill catalog summary is appended after instructions + // 'compact' (default) — one line: the skill count and where to read the skills + // 'append' — the skill catalog summary is appended after instructions // 'prepend' — summary first, then instructions // 'replace' — instructions only; summary and channel hints dropped (falls back to 'append' if instructions is empty) // 'off' — no summary; instructions and channel hints still sent @@ -51,6 +53,7 @@ export default class FlightBotServer {} - Top-level `instructions` on `@FrontMcp` exposes a global system prompt to MCP clients - `skillsConfig.injectInstructions: 'append'` adds the skill catalog summary after the user prompt +- The default, `'compact'`, adds one line with the skill count and the `skill://index.json` pointer instead - Dynamic skills are picked up because the composer runs on every initialize request - The catalog only names skills the initializing caller may see (skill authorities and the `skills:filter` flow, e.g. feature flags) - Catalog summary is bounded at 16 KB with a truncation footer pointing at skill://index.json diff --git a/libs/skills/catalog/frontmcp-config/references/configure-skills-http.md b/libs/skills/catalog/frontmcp-config/references/configure-skills-http.md index d35bd4766..268382c56 100644 --- a/libs/skills/catalog/frontmcp-config/references/configure-skills-http.md +++ b/libs/skills/catalog/frontmcp-config/references/configure-skills-http.md @@ -1,7 +1,7 @@ --- name: configure-skills-http -description: Full reference for skillsConfig — HTTP catalog endpoints, auth, caching, instructions injection, and tamper-evident audit log. -tags: [config, skills, skills-http, llm-txt, instructions, audit, injection] +description: Full reference for skillsConfig — HTTP catalog endpoints, auth, caching, instructions injection, SKILL.md resource annotations, and tamper-evident audit log. +tags: [config, skills, skills-http, llm-txt, instructions, audit, injection, annotations] --- # Configure `skillsConfig` @@ -16,12 +16,13 @@ tags: [config, skills, skills-http, llm-txt, instructions, audit, injection] apps: [MainApp], // Server-level instructions, exposed as `instructions` on initialize. - // Combined with the skill catalog summary per skillsConfig.injectInstructions. + // Combined with the skills line or catalog summary per skillsConfig.injectInstructions. instructions: 'You are a helpful assistant for booking flights.', skillsConfig: { enabled: true, // turn on /skills, /llm.txt, skill:// resources mcpResources: true, // expose skills as MCP resources (skill://index.json, skill:///SKILL.md) + resourceAnnotations: 'no-priority', // 'no-priority' (default) | 'full' | 'off' llmTxt: true, // serve /llm.txt llmFullTxt: false, // serve /llm_full.txt (full SKILL.md bodies) auth: 'api-key', // 'inherit' (default) | 'public' | 'api-key' | 'bearer' @@ -32,7 +33,7 @@ tags: [config, skills, skills-http, llm-txt, instructions, audit, injection] redis: { provider: 'redis', host: 'localhost', port: 6379 }, ttlMs: 60_000, }, - injectInstructions: 'append', // 'off' | 'append' | 'prepend' | 'replace' + injectInstructions: 'compact', // 'compact' (default) | 'off' | 'append' | 'prepend' | 'replace' failOnInvalidSkills: true, // false: a 'strict' skill with a missing tool is logged, not fatal externalProvider: new RestSkillProvider({ mode: 'read-only' }), // external skill storage, see below audit: { @@ -57,22 +58,51 @@ The new top-level `instructions?: string` field on `@FrontMcp` is forwarded verb | -------------- | -------- | ------- | ----------------------------------------------------------------------- | | `instructions` | `string` | `''` | High-level prompt the server gives to the LLM client at connection time | -`skillsConfig.injectInstructions` controls whether (and how) FrontMCP appends a generated **skill catalog summary** to those instructions on every `initialize` request. +`skillsConfig.injectInstructions` controls what FrontMCP adds about the server's skills to those instructions on every `initialize` request: one short line by default, or a generated **skill catalog summary**. ## Skill Catalog Injection Policy | Mode | Behavior | | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `off` | The catalog summary is suppressed. Server `instructions` and channel hints are still surfaced. | -| `append` | Server `instructions`, then channel hints, then the catalog summary, joined by `\n\n---\n\n`. **(Default.)** | +| `compact` | Server `instructions`, then channel hints, then one line with the number of skills and where to read them, joined by `\n\n---\n\n`. No line when the caller sees no skill. **(Default.)** | +| `off` | Nothing about skills is added. Server `instructions` and channel hints are still surfaced. | +| `append` | Server `instructions`, then channel hints, then the catalog summary (every skill's name and description). The default up to 1.10.15. | | `prepend` | Catalog summary first, then channel hints, then server `instructions`. | | `replace` | Surface ONLY the server `instructions`; the catalog AND channel hints are dropped. When `instructions` is empty/undefined this falls back to `'append'` so a misconfig doesn't drop everything. | +The `compact` line reads: + +```text +This server has 3 skills: the `skill://index.json` resource lists each skill's `SKILL.md` URI. +``` + +With `mcpResources: false` it points at the `skills/*` methods instead: + +```text +This server has 3 skills: find one with the `skills/search` request and load it with `skills/load`. +``` + +**Default change (1.10.16):** up to 1.10.15 the default was `'append'`, which lists every skill's name and description (up to 16 KB). Some clients copy the server's instructions into every tool's description, so a large catalog was repeated once per tool. Set `injectInstructions: 'append'` to get the full catalog back. + The catalog summary is built by `composeInitializeInstructions(...)` and `buildSkillsCatalogSummary(...)` (exported from `@frontmcp/sdk`). It is bounded at **16 KB** with a truncation footer. Its header and footer point clients at `skill://index.json` (SEP-2640 — singular scheme), which lists each skill's `skill:///SKILL.md` URI. With `mcpResources: false` no `skill://` resource is served, so they point at the `skills/load` and `skills/search` methods instead, and `sep2640InInstructions` is ignored. > **Dynamic skills:** because the composer recomputes the summary on every `initialize` request, skills registered after server boot **are** picked up automatically. Their `skill:///SKILL.md` resources follow too: a skill registered or removed after boot (`this.scope.skills.registerSkillContent()` / `unregisterSkill()`, a skill-bundle plugin) is added to or dropped from `resources/list`, and clients get `notifications/resources/list_changed`. Up to 1.9.4 `resources/list` named only the skills present at startup. -> **Per caller:** the summary (and the SEP-2640 `skill://` hints under `sep2640InInstructions`) is composed for the client that initializes, like `skills/list`: it only names skills whose `authorities` that caller satisfies and that the hookable `skills:filter` flow keeps, so a flag-disabled skill's name and description are left out. With nothing gating a skill the instructions are unchanged. Transports use `composeCallerInstructions(scope, { ctx })`, exported from `@frontmcp/sdk` for custom transports. +> **Per caller:** the compact line or the summary (and the SEP-2640 `skill://` hints under `sep2640InInstructions`) is composed for the client that initializes, like `skills/list`: it only counts or names skills whose `authorities` that caller satisfies and that the hookable `skills:filter` flow keeps, so a flag-disabled skill's name and description are left out. With nothing gating a skill the instructions are unchanged. Transports use `composeCallerInstructions(scope, { ctx })`, exported from `@frontmcp/sdk` for custom transports. + +## SKILL.md Resource Annotations + +Each `skill:///SKILL.md` resource in `resources/list` carries the annotations `skillsConfig.resourceAnnotations` selects: + +| Mode | Annotations | +| ------------- | -------------------------------------------------------------------------------------------- | +| `no-priority` | `audience: ['assistant']`, plus `lastModified` for a file-backed skill. **(Default.)** | +| `full` | `audience: ['assistant']`, `priority: 0.8` (the SEP-2640 recommendation) and `lastModified`. | +| `off` | No `annotations` field. | + +**Default change (1.10.16):** up to 1.10.15 every `SKILL.md` resource carried `priority: 0.8`, and nothing turned it off. Some clients (for example a Rust MCP client whose JSON parser keeps arbitrary-precision numbers) fail the whole `resources/list` when an annotation is a number, so the default sends no numeric annotation. Set `resourceAnnotations: 'full'` to send `priority` again; a hook that deleted `priority` from the list is no longer needed. + +An unknown `injectInstructions` or `resourceAnnotations` value stops the server at boot: `@FrontMcp` throws `InvalidDecoratorMetadataError` for `skillsConfig`, naming the field and the accepted values. ## Skills-Only Connections (`?mode=skills_only`) @@ -185,17 +215,19 @@ See [`skill-audit-log`](../../frontmcp-extensibility/references/skill-audit-log. | ----------------------------------------- | ----------------------------------------------------------------------------------------------------- | | Local dev, no skills | `skillsConfig` unset | | Public server, hand-curated server prompt | `instructions: '...'`, `injectInstructions: 'off'` | -| Server with many dynamic skills | `injectInstructions: 'append'` (default) or `'prepend'` if skill guidance must lead the prompt | +| Server with many dynamic skills | `injectInstructions: 'compact'` (default): clients read `skill://index.json` when they need a skill | +| Clients must see every skill up front | `injectInstructions: 'append'`, or `'prepend'` if skill guidance must lead the prompt | +| Client that relies on resource `priority` | `resourceAnnotations: 'full'` | | Multi-pod production | `cache: { enabled: true, redis: {...} }`, `audit: { signer: Rs256, store: StorageAdapterAuditStore }` | | Compliance / forensic requirements | RS256 signer + persistent store + scheduled `verifyChain(...)` in CI | ## Examples -| Example | Level | Description | -| --------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------- | -| [`inject-instructions`](../examples/configure-skills-http/inject-instructions.md) | Basic | Set a server-level instructions string and append the skill catalog summary on every initialize response. | -| [`audit-log-basic`](../examples/configure-skills-http/audit-log-basic.md) | Basic | Enable the skill audit log with the in-memory store and HS256 signer for development and tests. | -| [`audit-log-redis`](../examples/configure-skills-http/audit-log-redis.md) | Advanced | Production-grade audit log with the Redis-backed StorageAdapterAuditStore and the RS256 bundle-signing key. | +| Example | Level | Description | +| --------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- | +| [`inject-instructions`](../examples/configure-skills-http/inject-instructions.md) | Basic | Set a server-level instructions string and append the full skill catalog summary on every initialize response. | +| [`audit-log-basic`](../examples/configure-skills-http/audit-log-basic.md) | Basic | Enable the skill audit log with the in-memory store and HS256 signer for development and tests. | +| [`audit-log-redis`](../examples/configure-skills-http/audit-log-redis.md) | Advanced | Production-grade audit log with the Redis-backed StorageAdapterAuditStore and the RS256 bundle-signing key. | > See all examples in [`examples/configure-skills-http/`](../examples/configure-skills-http/) diff --git a/libs/skills/catalog/frontmcp-development/references/decorators-guide.md b/libs/skills/catalog/frontmcp-development/references/decorators-guide.md index 99edf8aa3..40ba1beb1 100644 --- a/libs/skills/catalog/frontmcp-development/references/decorators-guide.md +++ b/libs/skills/catalog/frontmcp-development/references/decorators-guide.md @@ -64,7 +64,7 @@ FrontMCP uses a hierarchical decorator system. The nesting order is: | Field | Description | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `info` | `serverInfo` of every `initialize`: `name`, `version`, optional `title` (else the name), `websiteUrl`, `icons` | -| `instructions?` | Top-level server-level instructions (system prompt) surfaced in the MCP `initialize` response and the 2026-07-28 `server/discover` result. Combined with the channel hint and the skill catalog summary per `skillsConfig.injectInstructions`; sent by every transport, `createFetchHandler()` and `connect()` included | +| `instructions?` | Top-level server-level instructions (system prompt) surfaced in the MCP `initialize` response and the 2026-07-28 `server/discover` result. Combined with the channel hint and the skills line or catalog per `skillsConfig.injectInstructions`; sent by every transport, `createFetchHandler()` and `connect()` included | | `apps` | Array of `@App` classes to mount | | `serve?` | Auto-start HTTP server (default: `true`). Set `false` for programmatic usage | | `splitByApp?` | If `true`, each app gets its own scope and basePath. Default: `false` | @@ -74,7 +74,7 @@ FrontMCP uses a hierarchical decorator system. The nesting order is: | `tools?` | Standalone tools (outside apps, merged with app tools) | | `resources?` | Standalone resources (merged with app resources) | | `skills?` | Standalone skills (merged with app skills) | -| `skillsConfig?` | Skills HTTP endpoints (`/llm.txt`, `/skills`), MCP tool config, `injectInstructions` policy, and tamper-evident `audit` log — see `configure-skills-http` | +| `skillsConfig?` | Skills HTTP endpoints (`/llm.txt`, `/skills`), MCP tool config, `injectInstructions` policy, `SKILL.md` resource annotations, and tamper-evident `audit` log — see `configure-skills-http` | | `transport?` | Transport preset (`'modern'`, `'legacy'`, `'stateless-api'`, `'full'`) or object | | `auth?` | Authentication mode: `'public'`, `'transparent'`, `'local'`, `'remote'` | | `http?` | HTTP server options (port, host, cors, socketPath) | diff --git a/libs/skills/catalog/frontmcp-production-readiness/references/common-checklist.md b/libs/skills/catalog/frontmcp-production-readiness/references/common-checklist.md index 029ea7c65..a03426fce 100644 --- a/libs/skills/catalog/frontmcp-production-readiness/references/common-checklist.md +++ b/libs/skills/catalog/frontmcp-production-readiness/references/common-checklist.md @@ -162,7 +162,7 @@ These checks apply to ALL deployment targets. Run them first, then proceed to yo - [ ] Audit chain verifier (`verifyChain`) runs in CI on the latest tail - [ ] Single-writer constraint respected (v1.2.0): writes routed to a single elected leader pod - [ ] `instructions` is set or `skillsConfig.injectInstructions` tuned for clients -- [ ] Auto-injected skill catalog summary stays within the 16 KB ceiling (review on each catalog change) +- [ ] With `injectInstructions: 'append'`, `'prepend'` or `'replace'`, the skill catalog summary stays within the 16 KB ceiling (the default `'compact'` sends one line) ## ExtApps / Widgets (if enabled) diff --git a/libs/skills/catalog/skills-manifest.json b/libs/skills/catalog/skills-manifest.json index af0215455..99f53ef36 100644 --- a/libs/skills/catalog/skills-manifest.json +++ b/libs/skills/catalog/skills-manifest.json @@ -1066,7 +1066,7 @@ }, { "name": "configure-skills-http", - "description": "Full reference for skillsConfig — HTTP catalog endpoints, auth, caching, instructions injection, and tamper-evident audit log.", + "description": "Full reference for skillsConfig — HTTP catalog endpoints, auth, caching, instructions injection, SKILL.md resource annotations, and tamper-evident audit log.", "examples": [ { "name": "audit-log-basic", @@ -1095,12 +1095,13 @@ }, { "name": "inject-instructions", - "description": "Set a server-level instructions string and append the skill catalog summary on every initialize response.", + "description": "Set a server-level instructions string and append the full skill catalog summary on every initialize response.", "level": "basic", "tags": ["config", "skills", "instructions", "injection", "initialize"], "features": [ "Top-level `instructions` on `@FrontMcp` exposes a global system prompt to MCP clients", "`skillsConfig.injectInstructions: 'append'` adds the skill catalog summary after the user prompt", + "The default, `'compact'`, adds one line with the skill count and the `skill://index.json` pointer instead", "Dynamic skills are picked up because the composer runs on every initialize request", "The catalog only names skills the initializing caller may see (skill authorities and the `skills:filter` flow, e.g. feature flags)", "Catalog summary is bounded at 16 KB with a truncation footer pointing at skill://index.json" diff --git a/plugins/plugin-feature-flags/src/__tests__/feature-flag.flows.spec.ts b/plugins/plugin-feature-flags/src/__tests__/feature-flag.flows.spec.ts index dd5a755d2..edc3aaf1d 100644 --- a/plugins/plugin-feature-flags/src/__tests__/feature-flag.flows.spec.ts +++ b/plugins/plugin-feature-flags/src/__tests__/feature-flag.flows.spec.ts @@ -555,7 +555,10 @@ describe('FeatureFlagPlugin and the skill catalog in the initialize instructions } it('lists an enabled skill and leaves a disabled one out of the catalog', async () => { - const instructions = await instructionsFor(serverConfig); + const instructions = await instructionsFor({ + ...serverConfig, + skillsConfig: { enabled: true, injectInstructions: 'append' }, + }); expect(instructions).toContain('**enabled-skill**: Enabled flagged workflow'); expect(instructions).not.toContain('disabled-skill'); From fa583c0e0de2b99a1ef883cb44bb7935d44668c8 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 05:41:29 +0200 Subject: [PATCH 2/2] fix(sdk): name no skill count in the compact line when a read-only external provider serves skills A read-only skillsConfig.externalProvider serves skills that skill://index.json lists but the registry does not hold, so the compact line undercounted them, and sent no line at all when every skill came from the provider. It now reads "This server has skills: ..." in that case. The catalog no longer says 'off' adds nothing about skills (the sep2640InInstructions hints are still sent), and protocol-versions names the skills line for server/discover. --- .../__tests__/skills-http.options.spec.ts | 1 + .../types/options/skills-http/interfaces.ts | 10 +++---- .../__tests__/skill-external-provider.spec.ts | 26 +++++++++++++++++++ .../skill-instructions.helper.spec.ts | 21 ++++++++++++++- .../src/skill/skill-instructions.helper.ts | 12 +++++---- .../transport.local.skill-hints.spec.ts | 16 ++++++++++-- .../references/configure-skills-http.md | 4 ++- .../references/protocol-versions.md | 2 +- 8 files changed, 75 insertions(+), 17 deletions(-) diff --git a/libs/sdk/src/common/types/options/__tests__/skills-http.options.spec.ts b/libs/sdk/src/common/types/options/__tests__/skills-http.options.spec.ts index ff2a95c8a..ea587bf70 100644 --- a/libs/sdk/src/common/types/options/__tests__/skills-http.options.spec.ts +++ b/libs/sdk/src/common/types/options/__tests__/skills-http.options.spec.ts @@ -131,6 +131,7 @@ describe('skillsConfigOptionsSchema', () => { FrontMcp({ info: { name: 'invalid-skills-config', version: '1.0.0' }, apps: [], + serve: false, skillsConfig: { [option]: value }, } as never)(class InvalidSkillsConfigServer {}); diff --git a/libs/sdk/src/common/types/options/skills-http/interfaces.ts b/libs/sdk/src/common/types/options/skills-http/interfaces.ts index 08ba86946..d0550d70a 100644 --- a/libs/sdk/src/common/types/options/skills-http/interfaces.ts +++ b/libs/sdk/src/common/types/options/skills-http/interfaces.ts @@ -78,12 +78,7 @@ import type { ExternalSkillProviderBase } from '../../../../skill/providers/exte */ export type SkillsConfigAuthMode = 'inherit' | 'public' | 'api-key' | 'bearer'; -/** - * Which annotations each `skill:///SKILL.md` resource carries in `resources/list`. - * - 'no-priority': `audience: ['assistant']` and `lastModified` (when known), no numeric annotation - * - 'full': also `priority: 0.8`, the SEP-2640 recommendation - * - 'off': no annotations - */ +/** Which annotations each `SKILL.md` resource carries in `resources/list`; see `SkillsConfigOptions.resourceAnnotations`. */ export type SkillResourceAnnotations = 'full' | 'no-priority' | 'off'; /** @@ -388,7 +383,8 @@ export interface SkillsConfigOptions { * with the number of skills the caller may see and where to read them * (`skill://index.json`, or the `skills/search` and `skills/load` * methods when `mcpResources` is false). No line when the caller sees no - * skill. Some clients copy the instructions into every tool description, + * skill; no count when a read-only `externalProvider` serves skills. + * Some clients copy the instructions into every tool description, * so the full catalog can cost many tokens per tool. * - `'append'`: `instructions` is sent as-is, followed by a * `\n\n---\n\n` separator and the per-skill summary (`**Name**: …` diff --git a/libs/sdk/src/skill/__tests__/skill-external-provider.spec.ts b/libs/sdk/src/skill/__tests__/skill-external-provider.spec.ts index 87c2a15cc..69fddfe19 100644 --- a/libs/sdk/src/skill/__tests__/skill-external-provider.spec.ts +++ b/libs/sdk/src/skill/__tests__/skill-external-provider.spec.ts @@ -8,7 +8,9 @@ import 'reflect-metadata'; import { App, LogLevel, ScopeEntry, skill, Tool, ToolContext } from '../../common'; import type { SkillContent } from '../../common/interfaces'; import { connect, type DirectClient } from '../../direct'; +import { FrontMcpInstance } from '../../front-mcp/front-mcp'; import { ExternalSkillProviderBase } from '../providers/external-skill.provider'; +import { composeCallerInstructions } from '../skill-instructions.helper'; import type { SkillSearchResult } from '../skill-storage.interface'; const remoteSkill: SkillContent = { @@ -186,3 +188,27 @@ describe('skillsConfig.externalProvider', () => { expect((skillMd.contents[0] as { text?: string }).text).toContain('name: remote-skill'); }); }); + +describe('skillsConfig.externalProvider in the default initialize instructions', () => { + const indexLine = "This server has skills: the `skill://index.json` resource lists each skill's `SKILL.md` URI."; + + async function instructionsWith(apps: Array): Promise { + const instance = await FrontMcpInstance.createForGraph({ + info: { name: 'external-skills-instructions', version: '1.0.0' }, + apps, + skillsConfig: { externalProvider: new InMemoryExternalProvider({ mode: 'read-only' }) }, + logging: { level: LogLevel.Off }, + }); + const [scope] = instance.getScopes(); + if (!scope) throw new Error('the config produced no scope'); + return composeCallerInstructions(scope, { ctx: { authInfo: {} } }); + } + + it("counts no skill, as skill://index.json also lists the provider's", async () => { + await expect(instructionsWith([DeskApp])).resolves.toBe(indexLine); + }); + + it('points at skill://index.json on a server that declares no skill itself', async () => { + await expect(instructionsWith([BareApp])).resolves.toBe(indexLine); + }); +}); diff --git a/libs/sdk/src/skill/__tests__/skill-instructions.helper.spec.ts b/libs/sdk/src/skill/__tests__/skill-instructions.helper.spec.ts index 8b8c23ea2..6d7c75dd3 100644 --- a/libs/sdk/src/skill/__tests__/skill-instructions.helper.spec.ts +++ b/libs/sdk/src/skill/__tests__/skill-instructions.helper.spec.ts @@ -25,7 +25,7 @@ function asEntry(skill: FakeSkill) { function makeRegistry(fakeSkills: FakeSkill[]): SkillRegistryInterface { const skills = fakeSkills.map(asEntry); - // Only `getSkills` is exercised by the helper; everything else can throw. + // Only `getSkills` and `getExternalProvider` are exercised by the helper; everything else can throw. const registry: Partial = { getSkills: ((opts?: unknown) => { const o = (opts ?? {}) as { visibility?: string }; @@ -36,6 +36,7 @@ function makeRegistry(fakeSkills: FakeSkill[]): SkillRegistryInterface { return v === o.visibility; }) as never; }) as SkillRegistryInterface['getSkills'], + getExternalProvider: () => undefined, }; return registry as SkillRegistryInterface; } @@ -56,6 +57,7 @@ function makeMutableRegistry(initial: FakeSkill[]): { return v === o.visibility; }) as never; }) as SkillRegistryInterface['getSkills'], + getExternalProvider: () => undefined, }; return { registry: registry as SkillRegistryInterface, @@ -390,6 +392,23 @@ describe('skill-instructions.helper', () => { expect(composeInitializeInstructions({ userInstructions: 'User.' })).toBe('User.'); }); + it.each([ + ['beside registered skills', twoSkills], + ['on its own', makeRegistry([])], + ])('counts no skill when a read-only external provider serves skills %s', (_case, registry) => { + const withProvider = { ...registry, getExternalProvider: () => ({ isReadOnly: () => true }) }; + expect(composeInitializeInstructions({ skillRegistry: withProvider as SkillRegistryInterface })).toBe( + resourceLine('skills'), + ); + }); + + it('keeps the count beside a persistent external provider, which serves the local skills', () => { + const withProvider = { ...twoSkills, getExternalProvider: () => ({ isReadOnly: () => false }) }; + expect(composeInitializeInstructions({ skillRegistry: withProvider as SkillRegistryInterface })).toBe( + resourceLine('2 skills'), + ); + }); + it('counts only the skills the skills:filter flow keeps for the caller', async () => { const scope = { metadata: {}, diff --git a/libs/sdk/src/skill/skill-instructions.helper.ts b/libs/sdk/src/skill/skill-instructions.helper.ts index e1c8ed60c..5a60e9b31 100644 --- a/libs/sdk/src/skill/skill-instructions.helper.ts +++ b/libs/sdk/src/skill/skill-instructions.helper.ts @@ -218,17 +218,19 @@ export function buildSkillsCatalogSummary( return lines.join('\n'); } -/** - * The `'compact'` line: how many skills there are and where to read them. Empty when there are none. - */ +/** The `'compact'` line: how many skills there are and where to read them. Empty when there are none. */ function buildCompactSkillsLine( skillRegistry: SkillRegistryInterface | undefined, options: SkillsCatalogSummaryOptions, ): string { const count = listedSkills(skillRegistry, options).length; - if (count === 0) return ''; + // A read-only external provider serves skills the registry does not hold, so none can be counted. + const servesExternalSkills = skillRegistry?.getExternalProvider()?.isReadOnly() === true; + if (count === 0 && !servesExternalSkills) return ''; + let skills = count === 1 ? '1 skill' : `${count} skills`; + if (servesExternalSkills) skills = 'skills'; const pointers = options.mcpResources === false ? METHOD_POINTERS : RESOURCE_POINTERS; - return `This server has ${count === 1 ? '1 skill' : `${count} skills`}: ${pointers.compact}`; + return `This server has ${skills}: ${pointers.compact}`; } /** diff --git a/libs/sdk/src/transport/adapters/__tests__/transport.local.skill-hints.spec.ts b/libs/sdk/src/transport/adapters/__tests__/transport.local.skill-hints.spec.ts index 7403ae52f..24591a0f2 100644 --- a/libs/sdk/src/transport/adapters/__tests__/transport.local.skill-hints.spec.ts +++ b/libs/sdk/src/transport/adapters/__tests__/transport.local.skill-hints.spec.ts @@ -14,7 +14,7 @@ import { composeCallerInstructions, type InstructionsScope } from '../../../skil type SkillsConfig = { sep2640InInstructions?: boolean; mcpResources?: boolean; - injectInstructions?: 'off' | 'append' | 'prepend' | 'replace'; + injectInstructions?: 'off' | 'compact' | 'append' | 'prepend' | 'replace'; }; const DEPLOY_HINT = 'skill://ops/deploy/SKILL.md — Deploy to production'; @@ -31,7 +31,12 @@ function makeScope( }; return { metadata: { skillsConfig, instructions }, - skills: { hasAny: () => true, getSkills: () => [skill], getSep2640InstructionUris: () => [] }, + skills: { + hasAny: () => true, + getSkills: () => [skill], + getSep2640InstructionUris: () => [], + getExternalProvider: () => undefined, + }, logger: { warn: jest.fn() }, runFlowForOutput: async (_flow: string, input: { skills: SkillEntry[] }) => ({ skills: servable(input.skills) }), } as unknown as InstructionsScope; @@ -57,6 +62,13 @@ describe('initialize instructions: SEP-2640 skill URI hints', () => { expect(await hintsFor(scope)).toBe('Server prompt.'); }); + it("keeps the hints under 'off', which drops only the skills line and catalog", async () => { + const scope = makeScope({ sep2640InInstructions: true, injectInstructions: 'off' }, 'Server prompt.'); + expect(await hintsFor(scope)).toBe( + `Server prompt.\n\n---\n\nAvailable skills (load via resources/read):\n- ${DEPLOY_HINT}`, + ); + }); + it("keeps the hints when 'replace' falls back to 'append' for empty instructions", async () => { const scope = makeScope({ sep2640InInstructions: true, injectInstructions: 'replace' }, ' '); expect(await hintsFor(scope)).toContain('skill://ops/deploy/SKILL.md'); diff --git a/libs/skills/catalog/frontmcp-config/references/configure-skills-http.md b/libs/skills/catalog/frontmcp-config/references/configure-skills-http.md index 268382c56..b433a250c 100644 --- a/libs/skills/catalog/frontmcp-config/references/configure-skills-http.md +++ b/libs/skills/catalog/frontmcp-config/references/configure-skills-http.md @@ -65,7 +65,7 @@ The new top-level `instructions?: string` field on `@FrontMcp` is forwarded verb | Mode | Behavior | | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `compact` | Server `instructions`, then channel hints, then one line with the number of skills and where to read them, joined by `\n\n---\n\n`. No line when the caller sees no skill. **(Default.)** | -| `off` | Nothing about skills is added. Server `instructions` and channel hints are still surfaced. | +| `off` | No skills line or catalog. Server `instructions`, channel hints and the `sep2640InInstructions` hints are still surfaced. | | `append` | Server `instructions`, then channel hints, then the catalog summary (every skill's name and description). The default up to 1.10.15. | | `prepend` | Catalog summary first, then channel hints, then server `instructions`. | | `replace` | Surface ONLY the server `instructions`; the catalog AND channel hints are dropped. When `instructions` is empty/undefined this falls back to `'append'` so a misconfig doesn't drop everything. | @@ -82,6 +82,8 @@ With `mcpResources: false` it points at the `skills/*` methods instead: This server has 3 skills: find one with the `skills/search` request and load it with `skills/load`. ``` +With a read-only `externalProvider` the line names no number (`This server has skills: …`): the provider's skills are listed by `skill://index.json` and found by `skills/search`, not held by the registry, so they can't be counted. The line is sent even when the server declares no skill itself. + **Default change (1.10.16):** up to 1.10.15 the default was `'append'`, which lists every skill's name and description (up to 16 KB). Some clients copy the server's instructions into every tool's description, so a large catalog was repeated once per tool. Set `injectInstructions: 'append'` to get the full catalog back. The catalog summary is built by `composeInitializeInstructions(...)` and `buildSkillsCatalogSummary(...)` (exported from `@frontmcp/sdk`). It is bounded at **16 KB** with a truncation footer. Its header and footer point clients at `skill://index.json` (SEP-2640 — singular scheme), which lists each skill's `skill:///SKILL.md` URI. With `mcpResources: false` no `skill://` resource is served, so they point at the `skills/load` and `skills/search` methods instead, and `sep2640InInstructions` is ignored. diff --git a/libs/skills/catalog/frontmcp-deployment/references/protocol-versions.md b/libs/skills/catalog/frontmcp-deployment/references/protocol-versions.md index 817fd4294..b63a3d86e 100644 --- a/libs/skills/catalog/frontmcp-deployment/references/protocol-versions.md +++ b/libs/skills/catalog/frontmcp-deployment/references/protocol-versions.md @@ -115,7 +115,7 @@ contract. | Tasks | core protocol | `io.modelcontextprotocol/tasks` extension | | Not found | `-32002` | `-32602` | -`server/discover` returns the capabilities and `instructions` `initialize` would: `experimental['claude/channel']` when the server has channels, and the server's `instructions` with the channel hint and the skill catalog (`skillsConfig.injectInstructions`) for that caller. Up to 1.9.2 it sent neither the channel capability nor the hint and catalog. +`server/discover` returns the capabilities and `instructions` `initialize` would: `experimental['claude/channel']` when the server has channels, and the server's `instructions` with the channel hint and the skills line or catalog (`skillsConfig.injectInstructions`) for that caller. Up to 1.9.2 it sent neither the channel capability nor the hint and catalog. ## Mirrored request headers