Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
74c8490
feat(sdk,auth,plugin-dashboard): serve @HttpRoute pages through a hoo…
frontegg-david Nov 2, 2026
01b85c0
fix(sdk,auth): keep the route and people schemas out of the browser b…
frontegg-david Nov 2, 2026
c5c20c2
docs(skills): add create-http-route and document http.auth.people, th…
frontegg-david Nov 2, 2026
f6f3995
test(sdk,plugin-dashboard): check HttpRouteHook in the typed hook fix…
frontegg-david Nov 2, 2026
7159435
refactor(sdk): send any http output but next and consumed as a route'…
frontegg-david Nov 2, 2026
899949a
fix(sdk): count X-Forwarded-Host as the server's own host in the same…
frontegg-david Nov 2, 2026
ec529b7
fix(sdk): wait for a full socket and end an sse output whose client a…
frontegg-david Nov 2, 2026
5ebb9a3
fix(sdk): end a people SSE stream with its session, and keep route cl…
frontegg-david Nov 2, 2026
3d182ef
Merge remote-tracking branch 'origin/feat/m20-shared-server-attach' i…
frontegg-david Nov 2, 2026
9c98e27
test(e2e): fit the browser bundle budgets to the web host on top of #…
frontegg-david Nov 2, 2026
1ff6a69
Merge remote-tracking branch 'origin/feat/m20-shared-server-attach' i…
frontegg-david Nov 2, 2026
50e6c60
Merge remote-tracking branch 'origin/feat/m20-shared-server-attach' i…
frontegg-david Nov 2, 2026
24acbf1
test(e2e): fit the browser bundle budgets to the web host on top of #…
frontegg-david Nov 2, 2026
a536682
fix(sdk): refuse an @HttpRoute path with Express wildcards or groups,…
frontegg-david Nov 2, 2026
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 @@ -24,8 +24,8 @@ import {
} from './helpers/browser-bundle';

const BUDGETS: Array<{ entry: string; label: string; maxBytes: number }> = [
{ entry: 'create-tool.ts', label: 'create() + one tool', maxBytes: 1_333_269 },
{ entry: 'create-webmcp-tool.ts', label: 'create() + WebMcpPlugin + one tool', maxBytes: 1_340_136 },
{ entry: 'create-tool.ts', label: 'create() + one tool', maxBytes: 1_333_877 },
{ entry: 'create-webmcp-tool.ts', label: 'create() + WebMcpPlugin + one tool', maxBytes: 1_340_744 },
];

const VITE_INITIAL_CHUNK_MAX_BYTES = 960_000;
Expand All @@ -49,6 +49,7 @@ const FEATURE_MODULES: Record<string, RegExp> = {
duties: sdkModule('duty/'),
elicitation: sdkModule('elicitation/(store|flows)/|elicitation/send-elicitation-result\\.tool'),
health: sdkModule('health/'),
'http routes': sdkModule('http-route/'),
'http transport': sdkModule(
'scope/(flows/http\\.|http-scope\\.helper)|transport/(flows/|adapters/|transport\\.registry|web-fetch-handler)|server/(custom-routes\\.helper|server\\.instance)',
),
Expand Down
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 @@ -46,6 +46,7 @@ const fixtureFiles = [
'materialized-resources-must-compile.ts',
'duties-must-compile.ts',
'offload-must-compile.ts',
'http-routes-must-compile.ts',
'shared-server-must-compile.ts',
'durable-jobs-must-compile.ts',
'build-manifest-must-compile.ts',
Expand Down Expand Up @@ -217,6 +218,10 @@ describe('@frontmcp/sdk declarations in a strict consumer project', () => {
expect(describeEach(inFixtureFile('duties-must-compile.ts'))).toEqual([]);
});

it('types @HttpRoute, HttpRouteContext, SSE routes, HttpRouteHook and http.auth.people', () => {
expect(describeEach(inFixtureFile('http-routes-must-compile.ts'))).toEqual([]);
});

it('types this.offload() from the offloaded function, @Resource({ worker }) and the offload option', () => {
expect(describeEach(inFixtureFile('offload-must-compile.ts'))).toEqual([]);
});
Expand Down
85 changes: 85 additions & 0 deletions apps/e2e/demo-e2e-dts-consumer/fixture/http-routes-must-compile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import 'reflect-metadata';

import {
App,
FrontMcp,
httpRespond,
HttpRoute,
HttpRouteContext,
HttpRouteHook,
LoopbackTokenAuth,
resourceStore,
Tool,
ToolContext,
type HttpRouteMetadata,
type HttpRouteResult,
type PeopleSession,
} from '@frontmcp/sdk';

const boardStore = resourceStore();
const board = boardStore.key<string[]>('board', { initial: [] });

@HttpRoute({ method: 'GET', path: '/board/:id' })
export class BoardPage extends HttpRouteContext {
async handle(): Promise<HttpRouteResult> {
const person: PeopleSession | undefined = this.person;
return { id: this.params['id'], cards: (await board.get()).value, until: person?.expiresAt };
}
}

@HttpRoute({ method: 'GET', path: () => '/board/page', auth: 'public' })
export class BoardHtml extends HttpRouteContext {
handle() {
return httpRespond.html('<h1>board</h1>');
}
}

@HttpRoute({ method: 'GET', path: '/board/events', sse: { store: boardStore, topics: [board.name], coalesceMs: 25 } })
export class BoardEvents extends HttpRouteContext {
async handle() {
return (await board.get()).value;
}
}

@Tool({ name: 'sign-in-link', inputSchema: {} })
export class SignInLinkTool extends ToolContext {
async execute() {
const { code, expiresAt } = this.get(LoopbackTokenAuth).createCode();
return { path: `/_frontmcp/people/login?code=${code}`, expiresAt };
}
}

export class AuditRoutes {
@HttpRouteHook.Will('handle')
async audit(): Promise<void> {
return undefined;
}
}

@App({ name: 'board', tools: [SignInLinkTool], routes: [BoardPage, BoardHtml, BoardEvents] })
class BoardApp {}

@FrontMcp({
info: { name: 'web-host', version: '1.0.0' },
apps: [BoardApp],
http: { auth: { people: 'loopback-token' } },
})
export class WebHostServer {}

@FrontMcp({
info: { name: 'web-host-tuned', version: '1.0.0' },
apps: [BoardApp],
http: { auth: { people: { mode: 'loopback-token', codeTtlMs: 30_000, cookiePath: '/board' } } },
})
export class TunedWebHostServer {}

// @ts-expect-error a route needs a handle() method
@HttpRoute({ method: 'GET', path: '/nothing' })
export class RouteWithoutHandle {}

export class InvalidRoutes {
// @ts-expect-error method is GET, POST, PUT, PATCH or DELETE
static readonly method: HttpRouteMetadata = { method: 'TRACE', path: '/x' };
// @ts-expect-error auth is 'people' or 'public'
static readonly auth: HttpRouteMetadata = { method: 'GET', path: '/x', auth: 'mcp' };
}
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 @@ -36,6 +36,7 @@
"shared-server-must-compile.ts",
"durable-jobs-must-compile.ts",
"materialized-resources-must-compile.ts",
"duties-must-compile.ts"
"duties-must-compile.ts",
"http-routes-must-compile.ts"
]
}
182 changes: 182 additions & 0 deletions apps/e2e/demo-e2e-web-host/e2e/web-host.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/**
* The web host: `@HttpRoute` pages served beside the MCP endpoint through the `http:route` flow, behind people auth
* (`http.auth.people: 'loopback-token'`): the token printed at startup buys a one-time code, the code posted from the
* server's own page buys an HttpOnly cookie. Pages share the app's providers with its tools, a cross-site mutation is
* refused, and an SSE page hears a resource change a tool made.
*/
import { expect, test, type ServerFixture } from '@frontmcp/testing';

const TOKEN_LINE = /people sign-in token: ([A-Za-z0-9_-]{43})/;

function startupToken(server: ServerFixture): string {
const line = server.getLogs().find((entry) => TOKEN_LINE.test(entry));
if (!line) throw new Error('the server printed no sign-in token');
return (TOKEN_LINE.exec(line) as RegExpExecArray)[1];
}

async function signInCode(server: ServerFixture, token: string): Promise<string> {
const response = await fetch(`${server.info.baseUrl}/_frontmcp/people/code`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
expect(response.status).toBe(200);
return ((await response.json()) as { code: string }).code;
}

function postCode(server: ServerFixture, code: string, origin = server.info.baseUrl): Promise<Response> {
return fetch(`${server.info.baseUrl}/_frontmcp/people/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: origin },
body: new URLSearchParams({ code }).toString(),
redirect: 'manual',
});
}

async function signIn(server: ServerFixture): Promise<string> {
const response = await postCode(server, await signInCode(server, startupToken(server)));
expect(response.status).toBe(200);
const cookie = response.headers.get('set-cookie') ?? '';
return cookie.split(';')[0];
}

/** Read SSE events from `response` until `accept` takes one, with a generous deadline for a loaded machine. */
async function nextEvent<T>(
reader: ReadableStreamDefaultReader<Uint8Array>,
accept: (event: T) => boolean,
timeoutMs = 30_000,
): Promise<T> {
const decoder = new TextDecoder();
const deadline = Date.now() + timeoutMs;
let buffered = '';
while (Date.now() < deadline) {
const timeout = new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), deadline - Date.now()));
const chunk = await Promise.race([reader.read(), timeout]);
if (chunk === 'timeout' || chunk.done) break;
buffered += decoder.decode(chunk.value, { stream: true });
let boundary = buffered.indexOf('\n\n');
while (boundary !== -1) {
const block = buffered.slice(0, boundary);
buffered = buffered.slice(boundary + 2);
const data = block
.split('\n')
.filter((line) => line.startsWith('data: '))
.map((line) => line.slice(6))
.join('\n');
if (data) {
const event = JSON.parse(data) as T;
if (accept(event)) return event;
}
boundary = buffered.indexOf('\n\n');
}
}
throw new Error(`no matching event within ${timeoutMs} ms`);
}

test.describe('Web host E2E', () => {
test.use({ server: 'apps/e2e/demo-e2e-web-host/src/main.ts', project: 'demo-e2e-web-host', publicMode: true });

test('prints the sign-in token once, and never again', async ({ server }) => {
const token = startupToken(server);

expect(server.getLogs().join('\n').split(token).length - 1).toBe(1);
});

test('a person signs in with a one-time code posted from the server page', async ({ server }) => {
const { baseUrl } = server.info;
const code = await signInCode(server, startupToken(server));

const page = await fetch(`${baseUrl}/_frontmcp/people/login?code=${code}`);
expect(page.status).toBe(200);
expect(page.headers.get('set-cookie')).toBeNull();
expect(await page.text()).toContain('method="post"');

const login = await postCode(server, code);
expect(login.status).toBe(200);
const setCookie = login.headers.get('set-cookie') ?? '';
expect(setCookie).toMatch(/^frontmcp_people=[A-Za-z0-9_-]{43}; Path=\/; Max-Age=\d+; HttpOnly; SameSite=Strict$/);
expect(setCookie).not.toContain(startupToken(server));

const reused = await postCode(server, code);
expect(reused.status).toBe(401);

const board = await fetch(`${baseUrl}/board`, { headers: { Cookie: setCookie.split(';')[0] } });
expect(board.status).toBe(200);
});

test('a page refuses a browser that has not signed in, and a public page serves it', async ({ server }) => {
const { baseUrl } = server.info;

expect((await fetch(`${baseUrl}/board`)).status).toBe(401);
expect((await fetch(`${baseUrl}/board`, { headers: { Cookie: 'frontmcp_people=forged' } })).status).toBe(401);
expect(await (await fetch(`${baseUrl}/status`)).json()).toEqual({ ok: true });
});

test('refuses a code posted from another site', async ({ server }) => {
const code = await signInCode(server, startupToken(server));

const response = await postCode(server, code, 'https://evil.example');

expect(response.status).toBe(403);
expect(response.headers.get('set-cookie')).toBeNull();
});

test('refuses a cross-site mutation, and serves the same one from the server page', async ({ server }) => {
const { baseUrl } = server.info;
const cookie = await signIn(server);
const add = (origin: string) =>
fetch(`${baseUrl}/board/cards`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie, Origin: origin },
body: JSON.stringify({ title: 'from the page' }),
});

const crossSite = await add('https://evil.example');
expect(crossSite.status).toBe(403);

const sameOrigin = await add(baseUrl);
expect(sameOrigin.status).toBe(200);
});

test('an SSE page hears the resource change a tool makes', async ({ server, mcp }) => {
const cookie = await signIn(server);
const controller = new AbortController();
const response = await fetch(`${server.info.baseUrl}/board/events`, {
headers: { Cookie: cookie, Accept: 'text/event-stream' },
signal: controller.signal,
});
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('text/event-stream');
const reader = (response.body as ReadableStream<Uint8Array>).getReader();

try {
const first = await nextEvent<{ cards: string[]; revision: number }>(reader, () => true);
const added = await mcp.tools.call('add-card', { title: 'from an agent' });
expect(added).toBeSuccessful();

const update = await nextEvent<{ cards: string[]; revision: number }>(
reader,
(event) => event.revision > first.revision,
);
expect(update.cards).toContain('from an agent');
} finally {
controller.abort();
}
});

test('a page and a tool share the app providers', async ({ server, mcp }) => {
const cookie = await signIn(server);
const visitsFromTool = async () => ((await mcp.tools.call('record-visit', {})).json() as { visits: number }).visits;
const visitsFromPage = async () =>
(
(await (await fetch(`${server.info.baseUrl}/board`, { headers: { Cookie: cookie } })).json()) as {
visits: number;
}
).visits;

const first = await visitsFromTool();
const second = await visitsFromPage();
const third = await visitsFromTool();

expect([second, third]).toEqual([first + 1, first + 2]);
});
});
43 changes: 43 additions & 0 deletions apps/e2e/demo-e2e-web-host/jest.e2e.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { createRequire } from 'module';

import type { Config } from '@jest/types';

const require = createRequire(import.meta.url);
const e2eCoveragePreset = require('../../../jest.e2e.coverage.preset.js');

const config: Config.InitialOptions = {
displayName: 'demo-e2e-web-host',
preset: '../../../jest.preset.js',
testEnvironment: 'node',
testMatch: ['<rootDir>/e2e/**/*.e2e.spec.ts'],
testTimeout: 60000,
maxWorkers: 1,
setupFilesAfterEnv: ['<rootDir>/../../../libs/testing/src/setup.ts'],
transformIgnorePatterns: ['node_modules/(?!(jose)/)'],
transform: {
'^.+\\.[tj]s$': [
'@swc/jest',
{
jsc: {
parser: {
syntax: 'typescript',
decorators: true,
},
transform: {
decoratorMetadata: true,
},
target: 'es2022',
},
},
],
},
moduleNameMapper: {
'^@frontmcp/testing$': '<rootDir>/../../../libs/testing/src/index.ts',
'^@frontmcp/sdk$': '<rootDir>/../../../libs/sdk/src/index.ts',
'^@frontmcp/adapters$': '<rootDir>/../../../libs/adapters/src/index.ts',
},
coverageDirectory: '../../../coverage/e2e/demo-e2e-web-host',
...e2eCoveragePreset,
};

export default config;
Loading
Loading