Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});

Expand Down
10 changes: 9 additions & 1 deletion apps/e2e/demo-e2e-dts-consumer/fixture/skills-must-compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
FrontMcp,
type ScopeEntry,
type SkillContent,
type SkillsConfigOptions,
type SkillSearchResult,
} from '@frontmcp/sdk';

Expand Down Expand Up @@ -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' };
Original file line number Diff line number Diff line change
@@ -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 }) => {
Expand All @@ -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');
});
});
2 changes: 2 additions & 0 deletions apps/e2e/demo-e2e-feature-flags/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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 {}
16 changes: 16 additions & 0 deletions apps/e2e/demo-e2e-skills/e2e/skills-only-mode.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* - tools/list returns an empty array
* - tools/call is answered as for an unknown tool (`Tool "<name>" 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
Expand Down Expand Up @@ -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
Expand Down
42 changes: 41 additions & 1 deletion apps/e2e/demo-e2e-skills/e2e/skills-resources.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -66,6 +67,12 @@ interface SkillIndexDocument {
skills: SkillIndexEntry[];
}

async function listSkillMdResources(mcp: { resources: { list(): Promise<Resource[]> } }): Promise<Resource[]> {
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',
Expand Down Expand Up @@ -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<SkillIndexDocument>(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', () => {
Expand Down Expand Up @@ -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 });
}
});
});
2 changes: 2 additions & 0 deletions apps/e2e/demo-e2e-skills/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {}
Expand Down
7 changes: 7 additions & 0 deletions libs/sdk/src/common/decorators/front-mcp.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | undefined;
if (loggingFormat?.['transports']) {
throw new InvalidDecoratorMetadataError(
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -98,6 +102,42 @@ 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: [],
serve: false,
skillsConfig: { [option]: value },
} as never)(class InvalidSkillsConfigServer {});

expect(boot).toThrow(InvalidDecoratorMetadataError);
expect(boot).toThrow(new RegExp(`skillsConfig[\\s\\S]*${option}`));
});
});

describe('validation', () => {
Expand Down
1 change: 1 addition & 0 deletions libs/sdk/src/common/types/options/skills-http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type {
SkillsConfigOptions,
SkillsConfigEndpointConfig,
SkillsConfigAuthMode,
SkillResourceAnnotations,
SkillsConfigAuditOptions,
SkillsConfigJwtOptions,
SkillsConfigCacheOptions,
Expand Down
34 changes: 29 additions & 5 deletions libs/sdk/src/common/types/options/skills-http/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ import type { ExternalSkillProviderBase } from '../../../../skill/providers/exte
*/
export type SkillsConfigAuthMode = 'inherit' | 'public' | 'api-key' | 'bearer';

/** Which annotations each `SKILL.md` resource carries in `resources/list`; see `SkillsConfigOptions.resourceAnnotations`. */
export type SkillResourceAnnotations = 'full' | 'no-priority' | 'off';

/**
* JWT validation configuration for bearer auth mode.
*/
Expand Down Expand Up @@ -287,6 +290,19 @@ export interface SkillsConfigOptions {
*/
mcpResources?: boolean;

/**
* Which annotations each `skill://<skillPath>/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
Expand Down Expand Up @@ -363,26 +379,34 @@ 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; 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**: …`
* 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`.
* Server `instructions` AND channel hints are still surfaced — only
* 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).
Expand Down
3 changes: 2 additions & 1 deletion libs/sdk/src/common/types/options/skills-http/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion libs/sdk/src/skill/__tests__/skill-context.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading