feat(repository-plugin): load trusted package code
This commit is contained in:
@@ -141,8 +141,7 @@ describe('apply (plugin lifecycle)', () => {
|
||||
})
|
||||
|
||||
it('connects, syncs tools under the namespace, and registers a notification handler', async () => {
|
||||
apply(ctx, stdioConfig)
|
||||
await sleep(50)
|
||||
await apply(ctx, stdioConfig)
|
||||
|
||||
expect(mockConnect).toHaveBeenCalled()
|
||||
expect(mockListTools).toHaveBeenCalled()
|
||||
@@ -152,11 +151,10 @@ describe('apply (plugin lifecycle)', () => {
|
||||
})
|
||||
|
||||
it('rejects a duplicate serverName at load and leaves the first instance intact', async () => {
|
||||
apply(ctx, stdioConfig)
|
||||
await sleep(50)
|
||||
await apply(ctx, stdioConfig)
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
|
||||
expect(() => { apply(ctx, stdioConfig) }).toThrow(/serverName "srv" is already in use/)
|
||||
expect(() => { void apply(ctx, stdioConfig) }).toThrow(/serverName "srv" is already in use/)
|
||||
// First instance unaffected.
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
})
|
||||
@@ -165,8 +163,7 @@ describe('apply (plugin lifecycle)', () => {
|
||||
const first = new Context()
|
||||
await first.plugin(SystemPrompt)
|
||||
await first.plugin(ToolRegistry)
|
||||
apply(first, stdioConfig)
|
||||
await sleep(50)
|
||||
await apply(first, stdioConfig)
|
||||
|
||||
await first.fiber.dispose()
|
||||
await sleep(50)
|
||||
@@ -176,16 +173,17 @@ describe('apply (plugin lifecycle)', () => {
|
||||
const second = new Context()
|
||||
await second.plugin(SystemPrompt)
|
||||
await second.plugin(ToolRegistry)
|
||||
expect(() => { apply(second, stdioConfig) }).not.toThrow()
|
||||
await expect(apply(second, stdioConfig)).resolves.toBeUndefined()
|
||||
await second.fiber.dispose()
|
||||
})
|
||||
|
||||
it('scopes serverName reservations per app root', async () => {
|
||||
const other = await mountRegistry()
|
||||
|
||||
apply(ctx, stdioConfig)
|
||||
const first = apply(ctx, stdioConfig)
|
||||
// Same serverName on a DIFFERENT root is fine.
|
||||
expect(() => { apply(other, stdioConfig) }).not.toThrow()
|
||||
await sleep(50)
|
||||
const second = apply(other, stdioConfig)
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
expect(other.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
@@ -194,8 +192,7 @@ describe('apply (plugin lifecycle)', () => {
|
||||
it('logs error and registers no tools when connect fails; dispose is a no-op', async () => {
|
||||
mockConnect.mockRejectedValue(new Error('connection refused'))
|
||||
|
||||
apply(ctx, stdioConfig)
|
||||
await sleep(50)
|
||||
await apply(ctx, stdioConfig)
|
||||
|
||||
expect(mockListTools).not.toHaveBeenCalled()
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
|
||||
@@ -208,8 +205,7 @@ describe('apply (plugin lifecycle)', () => {
|
||||
})
|
||||
|
||||
it('re-syncs tools on ToolListChanged notification', async () => {
|
||||
apply(ctx, stdioConfig)
|
||||
await sleep(50)
|
||||
await apply(ctx, stdioConfig)
|
||||
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
|
||||
@@ -226,8 +222,7 @@ describe('apply (plugin lifecycle)', () => {
|
||||
})
|
||||
|
||||
it('keeps the previous generation when a re-sync fails', async () => {
|
||||
apply(ctx, stdioConfig)
|
||||
await sleep(50)
|
||||
await apply(ctx, stdioConfig)
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
|
||||
mockListTools.mockRejectedValue(new Error('flaky server'))
|
||||
@@ -242,7 +237,7 @@ describe('apply (plugin lifecycle)', () => {
|
||||
// Load through ctx.plugin so ONLY the plugin's fiber is disposed — the
|
||||
// registry must survive to observe the unregistration.
|
||||
const fiber = ctx.plugin({ name: 'mcp-client', inject: ['tools'], apply }, stdioConfig)
|
||||
await sleep(50)
|
||||
await fiber
|
||||
|
||||
// Advance to a second generation first.
|
||||
mockListTools.mockResolvedValue({
|
||||
@@ -264,8 +259,7 @@ describe('apply (plugin lifecycle)', () => {
|
||||
it('effect disposer handles client.close failure gracefully', async () => {
|
||||
mockClose.mockRejectedValue(new Error('already closed'))
|
||||
|
||||
apply(ctx, stdioConfig)
|
||||
await sleep(50)
|
||||
await apply(ctx, stdioConfig)
|
||||
|
||||
// Should not throw when dispose is triggered.
|
||||
await ctx.fiber.dispose()
|
||||
@@ -283,8 +277,7 @@ describe('apply (plugin lifecycle)', () => {
|
||||
toolCallTimeoutMs: 30_000,
|
||||
}
|
||||
|
||||
apply(ctx, httpConfig)
|
||||
await sleep(50)
|
||||
await apply(ctx, httpConfig)
|
||||
|
||||
expect(mockConnect).toHaveBeenCalled()
|
||||
expect(ctx.tools.get('mcp__web__remote')).toBeDefined()
|
||||
|
||||
@@ -43,21 +43,6 @@ async function mountRegistry(): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Apply the MCP client plugin and wait for tools to be registered. */
|
||||
async function applyAndWait(ctx: Context, config: Config, timeoutMs = 20_000): Promise<void> {
|
||||
// Annotated bindings (not withResolvers<void>()): the tests lint layer runs
|
||||
// no-invalid-void-type with default options, which rejects the explicit
|
||||
// type argument in call position but accepts the inferred form.
|
||||
const gate: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
const timer = setTimeout(
|
||||
() => { gate.reject(new Error(`applyAndWait timed out after ${timeoutMs}ms — no tools/change event`)) },
|
||||
timeoutMs,
|
||||
)
|
||||
ctx.on('tools/change', () => { clearTimeout(timer); gate.resolve() })
|
||||
apply(ctx, config)
|
||||
await gate.promise
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
const gate: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
setTimeout(gate.resolve, ms)
|
||||
@@ -94,7 +79,7 @@ describe('fixture server — controlled scenarios', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await mountRegistry()
|
||||
await applyAndWait(ctx, fixtureConfig)
|
||||
await apply(ctx, fixtureConfig)
|
||||
}, 30_000)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -180,9 +165,9 @@ describe('fixture server — duplicate serverName', () => {
|
||||
cwd: packageDir,
|
||||
toolCallTimeoutMs: 15_000,
|
||||
}
|
||||
await applyAndWait(ctx, config)
|
||||
await apply(ctx, config)
|
||||
|
||||
expect(() => { apply(ctx, config) }).toThrow(/serverName "dup" is already in use/)
|
||||
expect(() => { void apply(ctx, config) }).toThrow(/serverName "dup" is already in use/)
|
||||
|
||||
await ctx.fiber.dispose()
|
||||
await sleep(200)
|
||||
@@ -192,7 +177,7 @@ describe('fixture server — duplicate serverName', () => {
|
||||
describe('fixture server — disposal', () => {
|
||||
it('disposes cleanly without error', async () => {
|
||||
const ctx = await mountRegistry()
|
||||
await applyAndWait(ctx, {
|
||||
await apply(ctx, {
|
||||
transport: 'stdio',
|
||||
serverName: 'fixture',
|
||||
command: process.execPath,
|
||||
@@ -229,7 +214,7 @@ describe('server-everything — official test server', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await mountRegistry()
|
||||
await applyAndWait(ctx, config)
|
||||
await apply(ctx, config)
|
||||
}, 60_000)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -293,7 +278,7 @@ describe('server-filesystem — real filesystem operations', () => {
|
||||
cwd: '',
|
||||
toolCallTimeoutMs: 30_000,
|
||||
}
|
||||
await applyAndWait(ctx, config)
|
||||
await apply(ctx, config)
|
||||
}, 60_000)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -409,7 +394,7 @@ describe('streamable-http — in-process MCP server', () => {
|
||||
headers: { Authorization: 'Bearer e2e-test-token' },
|
||||
toolCallTimeoutMs: 15_000,
|
||||
}
|
||||
await applyAndWait(ctx, config)
|
||||
await apply(ctx, config)
|
||||
}, 30_000)
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
Reference in New Issue
Block a user