Skip to content

Commit d8105f4

Browse files
jonathanMLDevzho
andauthored
feat: ServerContext lifecycle — AsyncDisposable + stale-handler guards (cppalliance#148)
* feat: ServerContext lifecycle — AsyncDisposable + stale-handler guards * fixed format error * addressed ai reviews --------- Co-authored-by: zho <jornathanm910923@gmail.com>
1 parent 27d9a76 commit d8105f4

18 files changed

Lines changed: 281 additions & 12 deletions

src/alliance/setup.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
21
import { ALLIANCE_SERVER_INSTRUCTIONS } from '../constants.js';
32
import type { ServerConfig } from '../core/config.js';
43
import { getDefaultServerContext } from '../core/server/server-context.js';
54
import { resolveAllianceConfig } from './config.js';
6-
import { setupCoreServer } from '../core/setup.js';
5+
import { setupCoreServer, type ServerHandle } from '../core/setup.js';
76
import { registerBuiltinUrlGenerators } from './url-builtins.js';
87
import { registerGuidedQueryTool } from './tools/guided-query-tool.js';
98
import { registerSuggestQueryParamsTool } from './tools/suggest-query-params-tool.js';
@@ -14,7 +13,7 @@ import { registerSuggestQueryParamsTool } from './tools/suggest-query-params-too
1413
*
1514
* When `config` is omitted, resolves env via {@link resolveAllianceConfig} (Alliance index/rerank defaults when unset).
1615
*/
17-
export async function setupAllianceServer(config?: ServerConfig): Promise<McpServer> {
16+
export async function setupAllianceServer(config?: ServerConfig): Promise<ServerHandle> {
1817
const server = await setupCoreServer(config ?? resolveAllianceConfig({}), {
1918
instructions: ALLIANCE_SERVER_INSTRUCTIONS,
2019
});

src/alliance/tools/guided-query-tool.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { suggestQueryParams } from '../../core/server/query-suggestion.js';
1717
import { markSuggested } from '../../core/server/suggestion-flow.js';
1818
import {
1919
classifyToolCatchError,
20+
lifecycleToolError,
2021
logToolError,
2122
pineconeToolError,
2223
validationToolError,
@@ -82,6 +83,9 @@ export function registerGuidedQueryTool(server: McpServer, ctx?: ServerContext):
8283
},
8384
async (params) => {
8485
try {
86+
if (ctx?.disposed) {
87+
return jsonErrorResponse(lifecycleToolError('ServerContext has been disposed'));
88+
}
8589
const {
8690
user_query,
8791
namespace: inputNamespace,

src/alliance/tools/suggest-query-params-tool.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { ServerContext } from '../../core/server/server-context.js';
77
import { markSuggested } from '../../core/server/suggestion-flow.js';
88
import {
99
classifyToolCatchError,
10+
lifecycleToolError,
1011
logToolError,
1112
validationToolError,
1213
} from '../../core/server/tool-error.js';
@@ -37,6 +38,9 @@ export function registerSuggestQueryParamsTool(server: McpServer, ctx?: ServerCo
3738
},
3839
async (params) => {
3940
try {
41+
if (ctx?.disposed) {
42+
return jsonErrorResponse(lifecycleToolError('ServerContext has been disposed'));
43+
}
4044
const { namespace, user_query } = params;
4145
if (!user_query?.trim()) {
4246
return jsonErrorResponse(validationToolError('user_query cannot be empty', 'user_query'));

src/core/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,4 @@ export type {
4141
HybridQueryResult,
4242
HybridLegFailed,
4343
} from '../types.js';
44-
export { setupCoreServer, teardownServer } from './setup.js';
44+
export { setupCoreServer, teardownServer, type ServerHandle } from './setup.js';
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { afterEach, describe, expect, it } from 'vitest';
2+
import { PineconeClient } from '../pinecone-client.js';
3+
import { resolveConfig } from '../config.js';
4+
import { setPineconeClient } from '../server/client-context.js';
5+
import { setupCoreServer, teardownServer } from '../setup.js';
6+
import { resolveTestConfig } from './tools/test-helpers.js';
7+
import { ServerContext, createServer, teardownDefaultServerContext } from './server-context.js';
8+
9+
describe('ServerContext lifecycle', () => {
10+
afterEach(() => {
11+
teardownServer();
12+
});
13+
14+
it('sets disposed after teardown()', () => {
15+
const ctx = new ServerContext(resolveTestConfig());
16+
ctx.teardown();
17+
expect(ctx.disposed).toBe(true);
18+
});
19+
20+
it('sets disposed after teardownDefaultServerContext()', () => {
21+
const ctx = createServer(resolveTestConfig());
22+
teardownDefaultServerContext();
23+
expect(ctx.disposed).toBe(true);
24+
});
25+
26+
it('await using disposes ServerContext on scope exit', async () => {
27+
const config = resolveTestConfig();
28+
let ctx!: ServerContext;
29+
await (async () => {
30+
await using scoped = new ServerContext(config);
31+
ctx = scoped;
32+
expect(scoped.disposed).toBe(false);
33+
})();
34+
expect(ctx.disposed).toBe(true);
35+
});
36+
37+
it('await using on setupCoreServer return value tears down and allows re-setup', async () => {
38+
const cfg = resolveConfig({ apiKey: 'lifecycle-await-key', indexName: 'test-index' });
39+
setPineconeClient(
40+
new PineconeClient({
41+
apiKey: cfg.apiKey,
42+
indexName: cfg.indexName,
43+
rerankModel: cfg.rerankModel,
44+
defaultTopK: cfg.defaultTopK,
45+
})
46+
);
47+
48+
await (async () => {
49+
await using _server = await setupCoreServer(cfg);
50+
})();
51+
52+
await expect(setupCoreServer(cfg)).resolves.toBeDefined();
53+
teardownServer();
54+
});
55+
});

src/core/server/server-context.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ function buildPineconeClient(config: ServerConfig): PineconeClient {
4343
* Encapsulates per-server state: Pinecone client, config, URL registry,
4444
* suggest-flow gate, and namespaces cache.
4545
*/
46-
export class ServerContext {
46+
export class ServerContext implements AsyncDisposable {
47+
disposed = false;
4748
private client: PineconeClient | null = null;
4849
private configValue: ServerConfig | null = null;
4950
private readonly urlGenerators = new Map<string, UrlGeneratorFn>();
@@ -257,12 +258,21 @@ export class ServerContext {
257258

258259
/** Clear all encapsulated state (client handle, caches, registries). */
259260
teardown(): void {
261+
this.disposed = true;
260262
this.client = null;
261263
this.configValue = null;
262264
this.urlGenerators.clear();
263265
this.suggestionFlow.clear();
264266
this.namespacesCache = null;
265267
}
268+
269+
async [Symbol.asyncDispose](): Promise<void> {
270+
this.teardown();
271+
if (defaultContext === this) {
272+
defaultContext = null;
273+
pendingConfig = null;
274+
}
275+
}
266276
}
267277

268278
let defaultContext: ServerContext | null = null;

src/core/server/tool-error.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
22
import {
33
classifyToolCatchError,
44
flowGateToolError,
5+
lifecycleToolError,
56
toolErrorSchema,
67
validationToolError,
78
} from './tool-error.js';
@@ -37,6 +38,14 @@ describe('ToolError schema and builders', () => {
3738
expect(toolErrorSchema.parse(err).code).toBe('PINECONE_ERROR');
3839
});
3940

41+
it('LIFECYCLE: not recoverable and parses', () => {
42+
const err = lifecycleToolError('ServerContext has been disposed');
43+
const parsed = toolErrorSchema.parse(err);
44+
expect(parsed.code).toBe('LIFECYCLE');
45+
expect(parsed.recoverable).toBe(false);
46+
expect(parsed.message).toContain('disposed');
47+
});
48+
4049
it('TIMEOUT: classifyToolCatchError matches withTimeout message prefix', () => {
4150
const err = classifyToolCatchError(
4251
new Error('Timeout after 100ms while waiting for query'),

src/core/server/tool-error.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@ export function logToolError(toolName: string, error: unknown): void {
1717
logError(`Error in ${toolName} tool`, error);
1818
}
1919

20-
export const toolErrorCodeSchema = z.enum(['FLOW_GATE', 'VALIDATION', 'PINECONE_ERROR', 'TIMEOUT']);
20+
export const toolErrorCodeSchema = z.enum([
21+
'FLOW_GATE',
22+
'VALIDATION',
23+
'PINECONE_ERROR',
24+
'TIMEOUT',
25+
'LIFECYCLE',
26+
]);
2127
export type ToolErrorCode = z.infer<typeof toolErrorCodeSchema>;
2228

2329
const flowGateToolErrorSchema = z.object({
@@ -49,11 +55,19 @@ const timeoutToolErrorSchema = z.object({
4955
suggestion: z.string().optional(),
5056
});
5157

58+
const lifecycleToolErrorSchema = z.object({
59+
code: z.literal('LIFECYCLE'),
60+
message: z.string(),
61+
recoverable: z.literal(false),
62+
suggestion: z.string().optional(),
63+
});
64+
5265
export const toolErrorSchema = z.discriminatedUnion('code', [
5366
flowGateToolErrorSchema,
5467
validationToolErrorSchema,
5568
pineconeToolErrorSchema,
5669
timeoutToolErrorSchema,
70+
lifecycleToolErrorSchema,
5771
]);
5872

5973
export type ToolError = z.infer<typeof toolErrorSchema>;
@@ -107,6 +121,14 @@ export function timeoutToolError(message: string, options?: { suggestion?: strin
107121
};
108122
}
109123

124+
export function lifecycleToolError(message: string): ToolError {
125+
return {
126+
code: 'LIFECYCLE',
127+
message,
128+
recoverable: false,
129+
};
130+
}
131+
110132
function rawErrorMessage(error: unknown): string {
111133
return error instanceof Error ? error.message : String(error);
112134
}

src/core/server/tools/count-tool.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { requireSuggested } from '../suggestion-flow.js';
88
import {
99
classifyToolCatchError,
1010
flowGateToolError,
11+
lifecycleToolError,
1112
logToolError,
1213
validationToolError,
1314
} from '../tool-error.js';
@@ -30,6 +31,9 @@ type CountExecParams = {
3031

3132
async function executeCount(params: CountExecParams, ctx?: ServerContext) {
3233
try {
34+
if (ctx?.disposed) {
35+
return jsonErrorResponse(lifecycleToolError('ServerContext has been disposed'));
36+
}
3337
const { namespace, query_text, metadata_filter } = params;
3438
const nsNorm = normalizeNamespace(namespace);
3539
if (!nsNorm) {

src/core/server/tools/generate-urls-tool.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import { z } from 'zod';
33
import { normalizeNamespace } from '../namespace-utils.js';
44
import type { ServerContext } from '../server-context.js';
55
import { generateUrlForNamespace } from '../url-registry.js';
6-
import { classifyToolCatchError, logToolError, validationToolError } from '../tool-error.js';
6+
import {
7+
classifyToolCatchError,
8+
lifecycleToolError,
9+
logToolError,
10+
validationToolError,
11+
} from '../tool-error.js';
712
import { jsonErrorResponse, jsonResponse } from '../tool-response.js';
813

914
/** Get metadata from a record (either record.metadata or the record itself). */
@@ -39,6 +44,9 @@ export function registerGenerateUrlsTool(server: McpServer, ctx?: ServerContext)
3944
},
4045
async (params) => {
4146
try {
47+
if (ctx?.disposed) {
48+
return jsonErrorResponse(lifecycleToolError('ServerContext has been disposed'));
49+
}
4250
const { namespace, records } = params;
4351
const nsNorm = normalizeNamespace(namespace);
4452
if (!nsNorm) {

0 commit comments

Comments
 (0)