diff --git a/src/__tests__/server.processUser.test.ts b/src/__tests__/server.processUser.test.ts new file mode 100644 index 00000000..714c4827 --- /dev/null +++ b/src/__tests__/server.processUser.test.ts @@ -0,0 +1,157 @@ +import { applyStaticProperty, getSetMemoKey } from '../server.processUser'; + +describe('applyStaticProperty', () => { + it.each([ + { + description: 'object', + obj: {}, + value: 'lorem', + name: 'ipsum', + expected: true + }, + { + description: 'function', + obj: () => {}, + value: 'lorem', + name: 'ipsum', + expected: true + } + ])('should apply a property, $description', ({ name, value, obj, expected }) => { + const result = applyStaticProperty(name, value, obj); + + expect(result).toBe(expected); + expect((obj as any)?.[name]).toBe(value); + + const descriptor = Object.getOwnPropertyDescriptor(obj, name); + + expect(descriptor).toBeDefined(); + expect(descriptor?.value).toBe(value); + expect(descriptor?.writable).toBe(false); + expect(descriptor?.enumerable).toBe(false); + expect(descriptor?.configurable).toBe(false); + }); + + it.each([ + { + description: 'null', + obj: null, + value: 'lorem', + name: 'ipsum', + expected: false + }, + { + description: 'undefined', + obj: undefined, + value: 'lorem', + name: 'ipsum', + expected: false + } + ])('should fail to apply a property, $description', ({ name, value, obj, expected }) => { + const result = applyStaticProperty(name, value, obj); + + expect(result).toBe(expected); + }); + + it('should return false when trying to overwrite a non-configurable property', () => { + const obj = {}; + + Object.defineProperty(obj, 'fixedProp', { + value: 'original', + writable: false, + configurable: false + }); + + const result = applyStaticProperty('fixedProp', 'newVal', obj); + + expect(result).toBe(false); + }); +}); + +describe('getSetMemoKey', () => { + it.each([ + { + description: 'string', + input: 'myString', + contextKey: 'ctx', + expected: 'myString:ctx' + }, + { + description: 'number', + input: 42, + contextKey: 'ctx', + expected: '42:ctx' + }, + { + description: 'boolean', + input: true, + contextKey: 'ctx', + expected: 'true:ctx' + }, + { + description: 'null', + input: null, + contextKey: 'ctx', + expected: 'null:ctx' + }, + { + description: 'undefined', + input: undefined, + contextKey: 'ctx', + expected: 'undefined:ctx' + } + ])('should return format "input:contextKey", $description', ({ input, contextKey, expected }) => { + const result = getSetMemoKey(input, contextKey); + + expect(result).toBe(expected); + }); + + it('should handle symbol primitives by converting to string representation', () => { + const sym = Symbol('testSym'); + const result = getSetMemoKey(sym, 'ctx'); + + expect(result).toBe('Symbol(testSym):ctx'); + }); + + it.each([ + { + description: 'object', + input: {} + }, + { + description: 'function', + input: () => {} + } + ])('should return a Symbol with plugins:contextKey description, $description', ({ input }) => { + const result = getSetMemoKey(input, 'someCtx'); + + expect(typeof result).toBe('symbol'); + expect((result as symbol).description).toBe('plugins:someCtx'); + }); + + it.each([ + { + description: 'object', + createRef: () => ({}) + }, + { + description: 'function', + createRef: () => () => {} + } + ])('should handle uniqueness and memoization for references, $description', ({ createRef }) => { + const ref1 = createRef(); + const ref2 = createRef(); + + const token1 = getSetMemoKey(ref1, 'myKey'); + const token2 = getSetMemoKey(ref1, 'myKey'); + + expect(token1).toBe(token2); + + const token3 = getSetMemoKey(ref2, 'myKey'); + + expect(token1).not.toBe(token3); + + const token4 = getSetMemoKey(ref1, 'otherKey'); + + expect(token1).not.toBe(token4); + }); +}); diff --git a/src/server.processUser.ts b/src/server.processUser.ts new file mode 100644 index 00000000..c0b394ef --- /dev/null +++ b/src/server.processUser.ts @@ -0,0 +1,61 @@ +import { isReferenceLike } from './server.helpers'; + +/** + * Apply a static property to an object. + * + * @param property - Name of the property to apply + * @param value - Value of the property to apply + * @param obj - Object to apply the property towards + * @returns `true` if the property was applied successfully, `false` otherwise. + */ +const applyStaticProperty = (property: string, value: unknown, obj: unknown) => { + try { + Object.defineProperty(obj, property, { value, writable: false, enumerable: false, configurable: false }); + } catch { + return false; + } + + return true; +}; + +/** + * Memoization key store. See `getSetMemoKey`. + */ +const pluginMemoKeyStore: WeakMap> = new WeakMap(); + +/** + * Quick consistent unique key, via symbol (anything unique-like will work), for a given input + * and context. + * + * Used specifically for helping memoize functions and objects against context. Not used + * elsewhere because simple equality checks, without context, in the lower-level functions + * are good enough. + * + * @param input - Input can be an object, function, or primitive value. + * @param contextKey - Additional context to help uniqueness. + * @returns A unique key, a symbol for objects/functions or string for primitives. + */ +const getSetMemoKey = (input: unknown, contextKey: string) => { + if (!isReferenceLike(input)) { + return `${String(input)}:${contextKey}`; + } + + let contextMap = pluginMemoKeyStore.get(input); + let token; + + if (!contextMap) { + contextMap = new Map(); + pluginMemoKeyStore.set(input, contextMap); + } + + token = contextMap.get(contextKey); + + if (!token) { + token = Symbol(`plugins:${contextKey}`); + contextMap.set(contextKey, token); + } + + return token; +}; + +export { applyStaticProperty, getSetMemoKey }; diff --git a/src/server.toolsHostCreator.ts b/src/server.toolsHostCreator.ts index 1a7010d8..d57fa417 100644 --- a/src/server.toolsHostCreator.ts +++ b/src/server.toolsHostCreator.ts @@ -3,6 +3,12 @@ import { type McpTool, type McpToolCreator } from './mcpSdk'; /** * Apply a static property to an object. * + * @note **Do not import the centralized helper from `server.processUser.ts`.** + * This duplication is intentional. This file should stay dependency-minimal. + * Importing shared helpers would pull in parent-side modules and widen the + * dependency graph, busting the lightweight child-process environment + * constraints. + * * @private * @param property - Name of the property to apply * @param value - Value of the property to apply diff --git a/src/server.toolsUser.ts b/src/server.toolsUser.ts index 2b9ce76d..78d93c9b 100644 --- a/src/server.toolsUser.ts +++ b/src/server.toolsUser.ts @@ -1,13 +1,14 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { dirname, isAbsolute, resolve } from 'node:path'; -import { isPath, isPlainObject, isReferenceLike, isUrl } from './server.helpers'; import { type McpTool } from './mcpSdk'; -import { type GlobalOptions } from './options'; +import { isPath, isPlainObject, isReferenceLike, isUrl } from './server.helpers'; import { memo } from './server.caching'; +import { applyStaticProperty, getSetMemoKey } from './server.processUser'; +import { normalizeInputSchema } from './server.schema'; +import { type GlobalOptions } from './options'; import { DEFAULT_OPTIONS } from './options.defaults'; import { type ToolOptions } from './options.tools'; import { formatUnknownError } from './logger'; -import { normalizeInputSchema } from './server.schema'; /** * Inline tool options. @@ -204,66 +205,6 @@ const ALLOWED_CONFIG_KEYS = new Set(['name', 'description', 'inputSchema', 'hand */ const ALLOWED_SCHEMA_KEYS = new Set(['description', 'inputSchema']); -/** - * Memoization key store. See `getSetMemoKey`. - */ -const toolsMemoKeyStore: WeakMap> = new WeakMap(); - -/** - * Quick consistent unique key, via symbol (anything unique-like will work), for a given input - * and context. - * - * Used specifically for helping memoize functions and objects against context. Not used - * elsewhere because simple equality checks, without context, in the lower-level functions - * are good enough. - * - * @private - * @param input - Input can be an object, function, or primitive value. - * @param contextKey - Additional context to help uniqueness. - * @returns A unique key, a symbol for objects/functions or string for primitives. - */ -const getSetMemoKey = (input: unknown, contextKey: string) => { - if (!isReferenceLike(input)) { - return `${String(input)}:${contextKey}`; - } - - let contextMap = toolsMemoKeyStore.get(input); - let token; - - if (!contextMap) { - contextMap = new Map(); - toolsMemoKeyStore.set(input, contextMap); - } - - token = contextMap.get(contextKey); - - if (!token) { - token = Symbol(`tools:${contextKey}`); - contextMap.set(contextKey, token); - } - - return token; -}; - -/** - * Apply a static property to an object. - * - * @private - * @param property - Name of the property to apply - * @param value - Value of the property to apply - * @param obj - Object to apply the property towards - * @returns `true` if the property was applied successfully, `false` otherwise. - */ -const applyStaticProperty = (property: string, value: unknown, obj: unknown) => { - try { - Object.defineProperty(obj, property, { value, writable: false, enumerable: false, configurable: false }); - } catch { - return false; - } - - return true; -}; - /** * Return an object key value. *