Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ import {
} from './helpers/browser-bundle';

const BUDGETS: Array<{ entry: string; label: string; maxBytes: number }> = [
{ entry: 'create-tool.ts', label: 'create() + one tool', maxBytes: 1_289_009 },
{ entry: 'create-webmcp-tool.ts', label: 'create() + WebMcpPlugin + one tool', maxBytes: 1_296_199 },
{ entry: 'create-tool.ts', label: 'create() + one tool', maxBytes: 1_290_698 },
{ entry: 'create-webmcp-tool.ts', label: 'create() + WebMcpPlugin + one tool', maxBytes: 1_297_891 },
];

const VITE_INITIAL_CHUNK_MAX_BYTES = 763_249;
const VITE_INITIAL_CHUNK_MAX_BYTES = 764_318;

// What create() never needs: the parser behind @enclave-vm/ast (#885), the MCP client (#886) and server-only code
const NEVER_BUNDLED: Record<string, RegExp> = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
"DidHookOf": "function",
"DistributedSemaphore": "class",
"DuplicateAppIdError": "class",
"DuplicatePluginEntryError": "class",
"DuplicatePluginError": "class",
"Duty": "function",
"DutyNameConflictError": "class",
Expand Down Expand Up @@ -372,6 +373,7 @@
"PayloadTooLargeError": "class",
"PersonNotSignedInError": "class",
"Plugin": "function",
"PluginConfiguredTwiceError": "class",
"PluginDependencyError": "class",
"PluginEntry": "class",
"PluginKind": "object",
Expand Down
2 changes: 1 addition & 1 deletion libs/observability/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ const shutdown = setupOTel({ serviceName: 'help-desk', exporter: 'otlp', endpoin

The log sinks are `{ type: 'stdout', pretty? }` (one JSON object per line), `{ type: 'console' }` (a readable line), `{ type: 'callback', fn }`, `{ type: 'otlp', endpoint?, headers?, batchSize?, flushIntervalMs?, serviceName? }`, `{ type: 'pino', logger }` and `{ type: 'winston', logger }`.

`@FrontMcp({ observability })` registers `ObservabilityPlugin` for you. You can register `ObservabilityPlugin.init(options)` in `plugins` yourself, but `observability` also connects the `logging` sinks to the server's logger, which the plugin alone doesn't.
`@FrontMcp({ observability })` registers `ObservabilityPlugin` for you. You can register `ObservabilityPlugin.init(options)` in `plugins` yourself instead, but `observability` also connects the `logging` sinks to the server's logger, which the plugin alone doesn't. Use one or the other: a server that sets `observability` and also lists `ObservabilityPlugin` in `@FrontMcp({ plugins })` fails at startup with `PluginConfiguredTwiceError` (code `PLUGIN_CONFIGURED_TWICE`).

## Key exports

Expand Down
14 changes: 14 additions & 0 deletions libs/sdk/src/errors/__tests__/misconfiguration.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
DuplicatePluginEntryError,
DuplicatePluginError,
JwtSecretRequiredError,
PluginConfiguredTwiceError,
SessionSecretRequiredError,
ToolAliasConflictError,
} from '../index';
Expand Down Expand Up @@ -45,6 +47,18 @@ describe('misconfiguration classification (#646)', () => {
expect(found?.remedy).toContain('Install it once');
});

it('recognizes a plugin listed twice in one plugins list', () => {
const found = findMisconfiguration(new DuplicatePluginEntryError('ledger', '@FrontMcp({ plugins })', 1, 2));
expect(found?.code).toBe('DUPLICATE_PLUGIN_ENTRY');
expect(found?.remedy).toContain('List it once with all its options');
});

it('recognizes a plugin a server option installs and the plugins list names too', () => {
const found = findMisconfiguration(new PluginConfiguredTwiceError('ObservabilityPlugin', 'observability'));
expect(found?.code).toBe('PLUGIN_CONFIGURED_TWICE');
expect(found?.remedy).toContain('Configure the plugin in one place');
});

it('maps a ZodError to CONFIG_INVALID', () => {
const err = new Error('bad config');
err.name = 'ZodError';
Expand Down
6 changes: 6 additions & 0 deletions libs/sdk/src/errors/misconfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ export const MISCONFIGURATION_REMEDIES: Record<string, string> = {
DUPLICATE_PLUGIN:
'A plugin that can be installed once per server is installed twice (in one plugins list, or by the server and ' +
'an app, or by two apps), so the server refuses to start. Install it once; the server log names the plugin.',
DUPLICATE_PLUGIN_ENTRY:
'One plugins list names a plugin twice, so the server refuses to start. List it once with all its options, ' +
'or install the other configuration on another app; the server log names the plugin, the list and both entries.',
PLUGIN_CONFIGURED_TWICE:
'A server option (such as `observability`) installs a plugin that the server plugins list names too, so the ' +
'server refuses to start. Configure the plugin in one place; the server log names the option and the plugin.',
};

/**
Expand Down
26 changes: 26 additions & 0 deletions libs/sdk/src/errors/plugin.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,29 @@ export class DuplicatePluginError extends PublicMcpError {
);
}
}

/** Thrown at startup when one plugins list names a plugin twice, which would install only its last entry. */
export class DuplicatePluginEntryError extends PublicMcpError {
constructor(pluginName: string, listName: string, firstEntry: number, secondEntry: number) {
super(
`Plugin "${pluginName}" is listed twice in ${listName} (entries ${firstEntry} and ${secondEntry}), and only ` +
'one entry could be installed. List it once, with all its options in that entry, or install the other ' +
'configuration on an @App of its own.',
'DUPLICATE_PLUGIN_ENTRY',
500,
);
}
}

/** Thrown at startup when a server option installs a plugin that the server's plugins list names too. */
export class PluginConfiguredTwiceError extends PublicMcpError {
constructor(pluginClassName: string, serverOption: string) {
super(
`${pluginClassName} is configured twice: by @FrontMcp({ ${serverOption} }) and by an ${pluginClassName} entry ` +
`in @FrontMcp({ plugins }). Configure it in one place: move the entry's options to ${serverOption} and ` +
`remove the entry, or remove ${serverOption}.`,
'PLUGIN_CONFIGURED_TWICE',
500,
);
}
}
162 changes: 162 additions & 0 deletions libs/sdk/src/plugin/__tests__/plugin.registry.duplicate-entry.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**
* One plugins list that names a plugin twice refuses to start.
*
* A plugins list keeps one entry per plugin token, so the second entry replaced the first and its
* options were dropped without a word. The same plugin in two lists (two apps, or the server and
* an app) is two installs, each with its own options, and keeps working.
*/
import 'reflect-metadata';

import { App, DynamicPlugin, LogLevel, Plugin, Tool, ToolContext, type ProviderType } from '../../common';
import { DuplicatePluginEntryError, DuplicatePluginError } from '../../errors';
import { FrontMcpInstance } from '../../front-mcp/front-mcp';

class LedgerOwner {
constructor(readonly name: string) {}
}

interface LedgerOptions {
owner: string;
}

@Plugin({ name: 'ledger' })
class LedgerPlugin extends DynamicPlugin<LedgerOptions> {
constructor(readonly options: LedgerOptions) {
super();
}

static override dynamicProviders(options: LedgerOptions): ProviderType[] {
return [{ name: 'ledger-owner', provide: LedgerOwner, useValue: new LedgerOwner(options.owner) }];
}
}

@Plugin({ name: 'audit' })
class AuditPlugin {}

@Plugin({ name: 'catalog', oncePerServer: 'One catalog holds every source.' })
class CatalogPlugin extends DynamicPlugin<LedgerOptions> {
constructor(readonly options: LedgerOptions) {
super();
}
}

@Tool({ name: 'billing_owner', inputSchema: {} })
class BillingOwnerTool extends ToolContext {
async execute() {
return { owner: this.get(LedgerOwner).name };
}
}

@Tool({ name: 'support_owner', inputSchema: {} })
class SupportOwnerTool extends ToolContext {
async execute() {
return { owner: this.get(LedgerOwner).name };
}
}

function startServer(config: Record<string, unknown>) {
return FrontMcpInstance.createDirect({
info: { name: 'duplicate-plugin-entry', version: '1.0.0' },
logging: { level: LogLevel.Off },
apps: [],
...config,
} as never);
}

@Plugin({ name: 'host', plugins: [LedgerPlugin.init({ owner: 'a' }), LedgerPlugin.init({ owner: 'b' })] })
class HostPlugin {}

function duplicateMessage(listName: string, firstEntry: number, secondEntry: number): string {
return `Plugin "ledger" is listed twice in ${listName} (entries ${firstEntry} and ${secondEntry}), and only one entry could be installed.`;
}

describe('PluginRegistry — a plugin listed twice in one plugins list', () => {
it('refuses to start a server whose plugins list names the plugin class twice', async () => {
const started = startServer({ plugins: [LedgerPlugin, LedgerPlugin] });

await expect(started).rejects.toThrow(DuplicatePluginEntryError);
await expect(started).rejects.toThrow(duplicateMessage('@FrontMcp({ plugins })', 1, 2));
});

it('refuses to start a server whose app lists two init() entries of the plugin, naming both entries', async () => {
@App({
id: 'billing',
name: 'Billing',
plugins: [LedgerPlugin.init({ owner: 'a' }), AuditPlugin, LedgerPlugin.init({ owner: 'b' })],
})
class BillingApp {}

const started = startServer({ apps: [BillingApp] });

await expect(started).rejects.toThrow(DuplicatePluginEntryError);
await expect(started).rejects.toThrow(duplicateMessage('the plugins of app "billing"', 1, 3));
});

it('refuses a list that names the plugin by its class and by init()', async () => {
await expect(startServer({ plugins: [LedgerPlugin.init({ owner: 'a' }), LedgerPlugin] })).rejects.toThrow(
duplicateMessage('@FrontMcp({ plugins })', 1, 2),
);
});

it("names a plugin's nested plugins list", async () => {
await expect(startServer({ plugins: [HostPlugin] })).rejects.toThrow(
duplicateMessage('the plugins of plugin "host"', 1, 2),
);
});

it('tells how to fix it, with the DUPLICATE_PLUGIN_ENTRY code', async () => {
const error = await startServer({ plugins: [LedgerPlugin, LedgerPlugin] }).catch((thrown: unknown) => thrown);

expect(error).toMatchObject({ code: 'DUPLICATE_PLUGIN_ENTRY' });
expect((error as Error).message).toContain(
'List it once, with all its options in that entry, or install the other configuration on an @App of its own.',
);
});

it('keeps DuplicatePluginError for a plugin installed once per server', async () => {
const started = startServer({
plugins: [CatalogPlugin.init({ owner: 'a' }), CatalogPlugin.init({ owner: 'b' })],
});

await expect(started).rejects.toThrow(DuplicatePluginError);
await expect(started).rejects.not.toThrow(DuplicatePluginEntryError);
});

it('lets two apps install the plugin, each with its own options', async () => {
@App({
id: 'billing',
name: 'Billing',
plugins: [LedgerPlugin.init({ owner: 'billing' })],
tools: [BillingOwnerTool],
})
class BillingApp {}
@App({
id: 'support',
name: 'Support',
plugins: [LedgerPlugin.init({ owner: 'support' })],
tools: [SupportOwnerTool],
})
class SupportApp {}

const server = await startServer({ apps: [BillingApp, SupportApp] });

expect((await server.callTool('billing_owner', {})).structuredContent).toEqual({ owner: 'billing' });
expect((await server.callTool('support_owner', {})).structuredContent).toEqual({ owner: 'support' });
await server.dispose();
});

it('lets the server and an app each install the plugin', async () => {
@App({
id: 'billing',
name: 'Billing',
plugins: [LedgerPlugin.init({ owner: 'billing' })],
tools: [BillingOwnerTool],
})
class BillingApp {}

const server = await startServer({ apps: [BillingApp], plugins: [LedgerPlugin.init({ owner: 'server' })] });

expect((await server.callTool('billing_owner', {})).structuredContent).toEqual({ owner: 'billing' });
await server.dispose();
});
});
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
/**
* A plugin declaring `oncePerServer` refuses a second install in the same server.
*
* A plugins list keeps one entry per plugin class, so listing a plugin twice dropped the first entry
* without a word; and two lists of one server (the server's and an app's, or two apps') installed two
* instances that registered the same providers, tools and skills over each other.
* Two lists of one server (the server's and an app's, or two apps') installed two instances that
* registered the same providers, tools and skills over each other. One list that names any plugin
* twice fails with DuplicatePluginEntryError instead; a oncePerServer plugin keeps its own error.
*/
import 'reflect-metadata';

import { App, DynamicPlugin, LogLevel, Plugin, Tool, ToolContext } from '../../common';
import { DuplicatePluginError } from '../../errors';
import { DuplicatePluginEntryError, DuplicatePluginError } from '../../errors';
import { FrontMcpInstance } from '../../front-mcp/front-mcp';

const REASON = 'One catalog can hold several sources.';
Expand Down Expand Up @@ -62,6 +62,31 @@ describe('PluginRegistry — a plugin installed once per server (oncePerServer)'
await expect(started).rejects.toThrow(DUPLICATE_MESSAGE);
});

it('refuses two entries of it that share its class but give different inline names', async () => {
const started = startServer({
plugins: [
{ ...CatalogPlugin.init({ source: 'a' }), name: 'catalog-a' },
{ ...CatalogPlugin.init({ source: 'b' }), name: 'catalog-b' },
],
});

await expect(started).rejects.toThrow(DuplicatePluginEntryError);
await expect(started).rejects.toThrow(
'Plugin "catalog-b" is listed twice in @FrontMcp({ plugins }) (entries 1 and 2)',
);
});

it('refuses a second entry that adds oncePerServer to a plugin whose first entry has none', async () => {
const started = startServer({
plugins: [CounterPlugin.init({ source: 'a' }), { ...CounterPlugin.init({ source: 'b' }), oncePerServer: REASON }],
});

await expect(started).rejects.toThrow(DuplicatePluginEntryError);
await expect(started).rejects.toThrow(
'Plugin "counter" is listed twice in @FrontMcp({ plugins }) (entries 1 and 2)',
);
});

it('refuses to start a server that installs it and whose app installs it too', async () => {
@App({ name: 'billing', plugins: [CatalogPlugin.init({ source: 'b' })] })
class BillingApp {}
Expand Down Expand Up @@ -110,12 +135,12 @@ describe('PluginRegistry — a plugin installed once per server (oncePerServer)'
await Promise.all([first.dispose(), second.dispose()]);
});

it('still starts a server that lists twice a plugin without oncePerServer', async () => {
const server = await startServer({
it('reports a plugin without oncePerServer listed twice as a duplicate entry, not as a second install', async () => {
const started = startServer({
plugins: [CounterPlugin.init({ source: 'a' }), CounterPlugin.init({ source: 'b' })],
});

expect((await server.listTools()).tools).toEqual([]);
await server.dispose();
await expect(started).rejects.toThrow(DuplicatePluginEntryError);
await expect(started).rejects.not.toThrow(DuplicatePluginError);
});
});
Loading
Loading