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
7 changes: 6 additions & 1 deletion apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const fixtureFiles = [
'host-must-compile.ts',
'skills-must-compile.ts',
'output-schemas-must-compile.ts',
'providers-must-compile.ts',
].map((fileName) => path.join(fixtureDir, fileName));

function compileFixture(withoutExpectErrors?: string): { diagnostics: FixtureDiagnostic[]; sourceFiles: string[] } {
Expand Down Expand Up @@ -129,14 +130,18 @@ 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([]);
});

it('types a z.union() or z.discriminatedUnion() outputSchema with no cast, and output.undeclaredKeys', () => {
expect(describeEach(inFixtureFile('output-schemas-must-compile.ts'))).toEqual([]);
});

it('types providers without a name, AsyncProvider with and without inject, useValue and symbol or string tokens in every entry point', () => {
expect(describeEach(inFixtureFile('providers-must-compile.ts'))).toEqual([]);
});

it('ships declaration files that type-check with skipLibCheck disabled', () => {
const sdkDeclarationDiagnostics = diagnostics.filter((diagnostic) =>
diagnostic.fileName.startsWith(sdkDeclarationsDir + path.sep),
Expand Down
79 changes: 79 additions & 0 deletions apps/e2e/demo-e2e-dts-consumer/fixture/providers-must-compile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import 'reflect-metadata';

import {
App,
AsyncProvider,
FrontMcp,
FrontMcpInstance,
Provider,
ProviderScope,
type FrontMcpConfigInput,
type ProviderType,
type Token,
} from '@frontmcp/sdk';

interface Settings {
region: string;
}

interface Store {
read(key: string): string | undefined;
}

const SETTINGS: Token<Settings> = Symbol('Settings');
const STORE: Token<Store> = Symbol('Store');
const GREETING: Token<string> = 'greeting';

@Provider()
class Clock {
now(): number {
return Date.now();
}
}

@Provider({ scope: ProviderScope.CONTEXT })
class RequestCounter {
count = 0;
}

const settingsValue = { provide: SETTINGS, useValue: { region: 'eu' } };

const greetingValue = { provide: GREETING, useValue: 'hello', name: 'greeting' };

const storeWithInject = AsyncProvider({
provide: STORE,
inject: () => [Clock] as const,
useFactory: async (clock: Clock): Promise<Store> => ({ read: (key) => `${clock.now()}:${key}` }),
});

const storeWithoutInject = AsyncProvider({
provide: STORE,
useFactory: (): Store => ({ read: () => undefined }),
});

const providers: ProviderType[] = [Clock, RequestCounter, settingsValue, greetingValue, storeWithInject];

@App({ name: 'providers', providers: [settingsValue, storeWithInject, storeWithoutInject, Clock] })
class ProvidersApp {}

@FrontMcp({
info: { name: 'providers', version: '1.0.0' },
apps: [ProvidersApp],
providers: [settingsValue, greetingValue, storeWithInject, storeWithoutInject, RequestCounter],
})
export class ProvidersServer {}

const config: FrontMcpConfigInput = {
info: { name: 'providers', version: '1.0.0' },
apps: [ProvidersApp],
providers,
};

export async function startEveryWay(): Promise<void> {
await FrontMcpInstance.runStdio({ ...config, providers: [storeWithoutInject, settingsValue] });
await FrontMcpInstance.bootstrap({ ...config, providers: [storeWithInject, settingsValue] });
const direct = await FrontMcpInstance.createDirect({ ...config, providers: [storeWithInject, greetingValue] });
await direct.dispose();
const socket = await FrontMcpInstance.runUnixSocket({ ...config, socketPath: '/tmp/providers.sock' });
await socket.close();
}
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' };
3 changes: 2 additions & 1 deletion apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"hooks-must-fail.ts",
"host-must-compile.ts",
"skills-must-compile.ts",
"output-schemas-must-compile.ts"
"output-schemas-must-compile.ts",
"providers-must-compile.ts"
]
}
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
35 changes: 35 additions & 0 deletions apps/e2e/demo-e2e-testing/e2e/jest-preset-unit.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { stripVTControlCharacters } from 'node:util';

const root = join(__dirname, '../../../..');
const fixtureDir = join(__dirname, '../fixture/ts-jest-cjs');
const jestBin = join(root, 'node_modules/jest/bin/jest.js');

function runFixtureJest(config: string): { status: number | null; output: string } {
const result = spawnSync(process.execPath, [jestBin, '--config', config, '--ci', '--no-cache'], {
cwd: fixtureDir,
env: { ...process.env, CI: '1' },
encoding: 'utf8',
timeout: 110_000,
});
return { status: result.status, output: stripVTControlCharacters(`${result.stdout}${result.stderr}`) };
}

describe('@frontmcp/testing/jest-preset-unit in a ts-jest CommonJS project', () => {
it('runs a spec that verifies a token with jose and runs a plugin hook in process', () => {
const { status, output } = runFixtureJest('jest.config.js');

expect(output).not.toMatch(/Unexpected token 'export'/);
expect(output).toMatch(/Tests:\s+2 passed, 2 total/);
expect(status).toBe(0);
});

it('is what that project needs: without the preset, Jest cannot load jose', () => {
const { status, output } = runFixtureJest('jest.without-preset.config.js');

expect(output).toMatch(/node_modules[/\\]jose[/\\]/);
expect(output).toMatch(/SyntaxError: Unexpected token 'export'/);
expect(status).not.toBe(0);
});
});
4 changes: 4 additions & 0 deletions apps/e2e/demo-e2e-testing/fixture/ts-jest-cjs/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports = {
preset: '@frontmcp/testing/jest-preset-unit',
transform: { '^.+\\.tsx?$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.json' }] },
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports = {
testEnvironment: 'node',
transform: { '^.+\\.tsx?$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.json' }] },
};
Loading
Loading