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
5 changes: 5 additions & 0 deletions 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 @@ -137,6 +138,10 @@ describe('@frontmcp/sdk declarations in a strict consumer project', () => {
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();
}
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"
]
}
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' }] },
};
109 changes: 109 additions & 0 deletions apps/e2e/demo-e2e-testing/fixture/ts-jest-cjs/src/auth-server.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import 'reflect-metadata';

import { MCP_20260728_META, PROTOCOL_2026_07_28 } from '@frontmcp/protocol';
import {
App,
AsyncProvider,
FrontMcpInstance,
LogLevel,
Plugin,
Tool,
ToolContext,
ToolHook,
type FlowCtxOf,
} from '@frontmcp/sdk';
import { TestTokenFactory } from '@frontmcp/testing';

const ISSUER = 'https://idp.example.com';
const GREETING = Symbol('greeting');
const toolsSeenByHook: string[] = [];

@Plugin({ name: 'call-recorder' })
class CallRecorderPlugin {
@ToolHook.Will('execute')
recordCall(ctx: FlowCtxOf<'tools:call-tool'>): void {
toolsSeenByHook.push(ctx.toolName);
}
}

@Tool({ name: 'greet', inputSchema: {} })
class GreetTool extends ToolContext {
async execute() {
return { greeting: `${this.get<string>(GREETING)} ${this.auth.user.sub}` };
}
}

@App({
name: 'greeter',
tools: [GreetTool],
plugins: [CallRecorderPlugin],
providers: [AsyncProvider({ provide: GREETING, useFactory: async () => 'hello' })],
})
class GreeterApp {}

const tokens = new TestTokenFactory({ issuer: ISSUER, audience: ISSUER });
let handler: (request: Request) => Promise<Response>;

beforeAll(async () => {
handler = await FrontMcpInstance.createFetchHandler({
info: { name: 'ts-jest-cjs', version: '1.0.0' },
apps: [GreeterApp],
logging: { level: LogLevel.Off },
auth: {
mode: 'transparent',
provider: ISSUER,
providerConfig: { jwks: await tokens.getPublicJwks() },
expectedAudience: ISSUER,
},
});
});

function callGreet(token: string): Promise<Response> {
return handler(
new Request('http://localhost/', {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
'mcp-protocol-version': PROTOCOL_2026_07_28,
'mcp-method': 'tools/call',
'mcp-name': 'greet',
authorization: `Bearer ${token}`,
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: {
name: 'greet',
arguments: {},
_meta: {
[MCP_20260728_META.protocolVersion]: PROTOCOL_2026_07_28,
[MCP_20260728_META.clientInfo]: { name: 'ts-jest-cjs', version: '1.0.0' },
[MCP_20260728_META.clientCapabilities]: {},
},
},
}),
}),
);
}

describe('a FrontMCP server in a ts-jest CommonJS project', () => {
it('verifies the bearer token with jose and runs the plugin hook in process', async () => {
const response = await callGreet(await tokens.createTestToken({ sub: 'ada' }));
const body = (await response.json()) as { result?: { structuredContent?: unknown } };

expect(response.status).toBe(200);
expect(body.result?.structuredContent).toEqual({ greeting: 'hello ada' });
expect(toolsSeenByHook).toEqual(['greet']);
});

it('refuses a token signed with another key', async () => {
const forged = await new TestTokenFactory({ issuer: ISSUER, audience: ISSUER }).createTestToken({ sub: 'eve' });

const response = await callGreet(forged);

expect(response.status).toBe(401);
expect(toolsSeenByHook).toEqual(['greet']);
});
});
13 changes: 13 additions & 0 deletions apps/e2e/demo-e2e-testing/fixture/ts-jest-cjs/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "es2022",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"skipLibCheck": true,
"types": ["jest", "node"]
},
"include": ["src/**/*.ts"]
}
10 changes: 5 additions & 5 deletions libs/di/src/__tests__/provider.metadata.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ describe('ProviderScope', () => {

describe('providerMetadataSchema', () => {
describe('valid inputs', () => {
it('should accept metadata without a name, which the token then provides', () => {
const result = providerMetadataSchema.safeParse({});
expect(result.success).toBe(true);
});

it('should accept minimal metadata with just name', () => {
const result = providerMetadataSchema.safeParse({ name: 'TestProvider' });
expect(result.success).toBe(true);
Expand Down Expand Up @@ -82,11 +87,6 @@ describe('providerMetadataSchema', () => {
});

describe('invalid inputs', () => {
it('should reject missing name', () => {
const result = providerMetadataSchema.safeParse({});
expect(result.success).toBe(false);
});

it('should reject empty name', () => {
const result = providerMetadataSchema.safeParse({ name: '' });
expect(result.success).toBe(false);
Expand Down
29 changes: 24 additions & 5 deletions libs/di/src/__tests__/token.utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@
*/

import 'reflect-metadata';

import { DESIGN_PARAMTYPES, META_ASYNC_WITH, META_ASYNC_WITH_TOKENS } from '../tokens/di.constants.js';
import {
tokenName,
depsOfClass,
depsOfFunc,
getAsyncWithTokens,
isClass,
isPromise,
getAsyncWithTokens,
readWithParamTypes,
depsOfClass,
depsOfFunc,
tokenName,
} from '../utils/token.utils.js';
import { DESIGN_PARAMTYPES, META_ASYNC_WITH, META_ASYNC_WITH_TOKENS } from '../tokens/di.constants.js';

describe('token utils', () => {
describe('tokenName', () => {
Expand All @@ -35,6 +36,24 @@ describe('token utils', () => {
expect(tokenName(null as any)).toBe('[ref]');
});

it('should return the description of a symbol token', () => {
expect(tokenName(Symbol('api-url'))).toBe('api-url');
});

it('should return [ref] for a symbol without a description', () => {
expect(tokenName(Symbol())).toBe('[ref]');
expect(tokenName(Symbol(''))).toBe('[ref]');
});

it('should return the function name for an arrow function token', () => {
const makeStore = () => ({});
expect(tokenName(makeStore)).toBe('makeStore');
});

it('should return a string token itself', () => {
expect(tokenName('greeting')).toBe('greeting');
});

it('should return function name for named functions', () => {
function myFunction() {}
expect(tokenName(myFunction)).toBe('myFunction');
Expand Down
6 changes: 2 additions & 4 deletions libs/di/src/metadata/provider.metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,8 @@ export interface ProviderMetadata {
*/
id?: string;

/**
* Human-readable name for the provider.
*/
name: string;
/** Name shown in logs and errors; `@frontmcp/sdk` names a provider without one after its token. */
name?: string;

/**
* Optional description of what the provider does.
Expand Down
2 changes: 1 addition & 1 deletion libs/di/src/metadata/provider.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export type RawZodShape<T> = {
export const providerMetadataSchema = z
.object({
id: z.string().optional(),
name: z.string().min(1),
name: z.string().min(1).optional(),
description: z.string().optional(),
scope: z.nativeEnum(ProviderScope).optional().default(ProviderScope.GLOBAL),
} satisfies RawZodShape<ProviderMetadata>)
Expand Down
9 changes: 6 additions & 3 deletions libs/di/src/utils/token.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@ import { getMetadata, hasAsyncWith } from './metadata.utils.js';
* Get a human-readable name for a token.
*
* @param t - The token to get the name for
* @returns A string representation of the token
* @returns The class name, the symbol's description, the string token itself, or `[ref]`
*/
export const tokenName = (t: Token): string =>
typeof t === 'function' && !!(t as any).prototype ? (t as any).name : ((t as any)?.name ?? '[ref]');
export const tokenName = (t: Token): string => {
if (typeof t === 'string') return t;
if (typeof t === 'symbol') return t.description || '[ref]';
return t?.name ?? '[ref]';
};

/**
* Check if a value is a class constructor.
Expand Down
2 changes: 1 addition & 1 deletion libs/sdk/src/common/decorators/provider.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { FrontMcpProviderTokens } from '../tokens';
/**
* Decorator that marks a class as a McpProvider module and provides metadata
*/
function FrontMcpProvider(providedMetadata: ProviderMetadata): ClassDecorator {
function FrontMcpProvider(providedMetadata: ProviderMetadata = {}): ClassDecorator {
return (target: Function) => {
const metadata = frontMcpProviderMetadataSchema.parse(providedMetadata);

Expand Down
Loading
Loading