feat(mcplocal+mcpd): wake-recipe config + wake-task execution (v2 Stage 1)
First half of v2 — mcplocal can now declare a hibernating backend and
respond to a `wake` task by running a configured recipe. v2 Stage 2
will wire mcpd to dispatch the wake task before relaying inference.
Config (LlmProviderFileEntry):
- New \`wake\` block on a published provider:
wake:
type: http # or: command
url: ... # http only
method: POST # http only, default POST
headers: {...} # http only
body: ... # http only
command: ... # command only
args: [...] # command only
maxWaitSeconds: 60 # how long to poll isAvailable() after wake fires
Registrar (mcplocal):
- At publish time, providers with a wake recipe whose isAvailable()
returns false report initialStatus=hibernating to mcpd. Without a
wake recipe (legacy v1) or when already up, status stays active.
- handleWakeTask: runs the recipe (HTTP request OR child-process
spawn), then polls isAvailable() up to maxWaitSeconds, sending a
heartbeat each loop so mcpd's GC sweep doesn't time us out
mid-boot. Reports { ok, ms } on success or { error } on
timeout/recipe failure via the existing _provider-task/:id/result.
- Replaces the v1 stub that rejected wake tasks with "not implemented".
mcpd VirtualLlmService:
- RegisterProviderInput gains optional initialStatus ('active' |
'hibernating'). The register/upsert path uses it for both new and
reconnecting rows. Defaults to 'active' so v1 publishers still
work unchanged.
- Provider-register route's coercer accepts the new field.
Tests: 3 new in registrar.test.ts cover initialStatus selection
(hibernating when wake configured + unavailable, active otherwise,
active when no wake even if unavailable). 8/8 registrar tests, 833/833
mcpd unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -142,13 +142,17 @@ describe('VirtualLlmRegistrar', () => {
|
||||
const registerCall = fake.calls.find((c) => c.path === '/api/v1/llms/_provider-register');
|
||||
expect(registerCall).toBeDefined();
|
||||
expect(registerCall!.method).toBe('POST');
|
||||
const body = JSON.parse(registerCall!.body) as { providers: Array<{ name: string; type: string; model: string; tier: string }> };
|
||||
expect(body.providers).toEqual([{
|
||||
const body = JSON.parse(registerCall!.body) as { providers: Array<Record<string, unknown>> };
|
||||
expect(body.providers).toHaveLength(1);
|
||||
expect(body.providers[0]).toMatchObject({
|
||||
name: 'vllm-local',
|
||||
type: 'openai',
|
||||
model: 'qwen',
|
||||
tier: 'fast',
|
||||
}]);
|
||||
// v2 always sends initialStatus; defaults to 'active' when no
|
||||
// wake recipe is configured.
|
||||
initialStatus: 'active',
|
||||
});
|
||||
expect(registerCall!.headers['authorization']).toBe('Bearer tok-abc');
|
||||
|
||||
// Sticky session id persisted.
|
||||
@@ -219,6 +223,107 @@ describe('VirtualLlmRegistrar', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── v2: hibernating + wake recipe ──
|
||||
|
||||
it('publishes initialStatus=hibernating when provider is unavailable AND wake is configured', async () => {
|
||||
const fake = await startFakeServer();
|
||||
try {
|
||||
const sleeping: LlmProvider = {
|
||||
name: 'vllm-local',
|
||||
async complete() { throw new Error('not running'); },
|
||||
async listModels() { return []; },
|
||||
async isAvailable() { return false; },
|
||||
};
|
||||
const registrar = new VirtualLlmRegistrar({
|
||||
mcpdUrl: fake.url,
|
||||
token: 't',
|
||||
publishedProviders: [{
|
||||
provider: sleeping,
|
||||
type: 'openai',
|
||||
model: 'm',
|
||||
wake: { type: 'http', url: 'http://localhost:9999/wake', maxWaitSeconds: 1 },
|
||||
}],
|
||||
sessionFilePath: join(tempDir, 'provider-session'),
|
||||
log: silentLog(),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
await registrar.start();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const registerCall = fake.calls.find((c) => c.path === '/api/v1/llms/_provider-register');
|
||||
const body = JSON.parse(registerCall!.body) as { providers: Array<{ initialStatus?: string }> };
|
||||
expect(body.providers[0]!.initialStatus).toBe('hibernating');
|
||||
registrar.stop();
|
||||
} finally {
|
||||
await fake.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('publishes initialStatus=active when provider is available even with a wake recipe', async () => {
|
||||
const fake = await startFakeServer();
|
||||
try {
|
||||
const awake: LlmProvider = {
|
||||
name: 'vllm-local',
|
||||
async complete() { throw new Error('not used'); },
|
||||
async listModels() { return []; },
|
||||
async isAvailable() { return true; },
|
||||
};
|
||||
const registrar = new VirtualLlmRegistrar({
|
||||
mcpdUrl: fake.url,
|
||||
token: 't',
|
||||
publishedProviders: [{
|
||||
provider: awake,
|
||||
type: 'openai',
|
||||
model: 'm',
|
||||
wake: { type: 'http', url: 'http://localhost:9999/wake' },
|
||||
}],
|
||||
sessionFilePath: join(tempDir, 'provider-session'),
|
||||
log: silentLog(),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
await registrar.start();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const registerCall = fake.calls.find((c) => c.path === '/api/v1/llms/_provider-register');
|
||||
const body = JSON.parse(registerCall!.body) as { providers: Array<{ initialStatus?: string }> };
|
||||
expect(body.providers[0]!.initialStatus).toBe('active');
|
||||
registrar.stop();
|
||||
} finally {
|
||||
await fake.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('publishes initialStatus=active when no wake recipe is configured (legacy path)', async () => {
|
||||
const fake = await startFakeServer();
|
||||
try {
|
||||
// Provider intentionally returns false but has no wake recipe →
|
||||
// legacy v1 publishers don't get hibernation behavior.
|
||||
const sleeping: LlmProvider = {
|
||||
name: 'vllm-local',
|
||||
async complete() { return { content: '', toolCalls: [], usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, finishReason: 'stop' }; },
|
||||
async listModels() { return []; },
|
||||
async isAvailable() { return false; },
|
||||
};
|
||||
const registrar = new VirtualLlmRegistrar({
|
||||
mcpdUrl: fake.url,
|
||||
token: 't',
|
||||
publishedProviders: [{ provider: sleeping, type: 'openai', model: 'm' }],
|
||||
sessionFilePath: join(tempDir, 'provider-session'),
|
||||
log: silentLog(),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
await registrar.start();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const registerCall = fake.calls.find((c) => c.path === '/api/v1/llms/_provider-register');
|
||||
const body = JSON.parse(registerCall!.body) as { providers: Array<{ initialStatus?: string }> };
|
||||
expect(body.providers[0]!.initialStatus).toBe('active');
|
||||
registrar.stop();
|
||||
} finally {
|
||||
await fake.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('throws when mcpd returns non-201 from /_provider-register', async () => {
|
||||
const fake = await startFakeServer();
|
||||
fake.handler = (_req, res, _body) => {
|
||||
|
||||
Reference in New Issue
Block a user