Skip to content

Commit 1ae98c8

Browse files
authored
feat(availability): extend tier matrix with per-parameter gating (#150)
* feat(availability): extend tier matrix with per-parameter gating (#136) Add parameter-level tier restrictions to the schema filtering pipeline. Parameters above the user's GitLab tier are stripped from JSON Schema before exposure to agents, preventing confusing API errors. - Add parameterRequirements map to ToolAvailability (manage_work_item: weight, iterationId, healthStatus) - Add getRestrictedParameters() method checking tier and version - Add stripTierRestrictedParameters() utility in schema-utils for both flat and discriminated union schemas - Integrate parameter stripping in registry-manager buildToolLookupCache pipeline * refactor(availability): add ParameterRequirement alias and improve debug log - Add ParameterRequirement type alias for semantic clarity (vs ActionRequirement) - Include version in restricted parameters debug log since parameters can be restricted by version, not just tier * test(availability): add parameter stripping edge case tests - Add RegistryManager test for schema property removal integration - Add schema-utils tests: no properties, no required array, discriminated union with missing properties branch * fix(availability): log raw version string and clean up test registry - Use human-readable version string (e.g. "17.0.0") in debug log instead of parsed numeric value (1700) - Wrap parameter stripping test in try/finally to clean up shared registry after test completes * docs: Prompt Library and advanced site features (Phase 2) (#143) * docs: add Prompt Library, guides, llms.txt, and use-case tool pages (#125) - Prompt Library with 16 pages of ready-to-use prompts organized by workflow (quick-start, code-review, CI/CD, project-management) and by role (developer, devops, team-lead, PM) - 4 step-by-step guides: code review, CI notifications, multi-GitLab setup, team onboarding - llms.txt for machine-readable documentation summary - Tool pages reorganized by use-case: code-review, ci-cd, project-management, repository - VitePress nav/sidebar updated with Prompts and Guides sections - Homepage updated with Prompt Library action button * docs: add Automate Releases guide and tool comparison tables - Add 5th guide: automate-releases.md (end-to-end release workflow) - Add tool comparison table by role (Developer/DevOps/Lead/PM) - Add Query vs Command comparison table for CQRS tools - Update sidebar and guides index with new guide * fix(docs): correct tool schemas in prompt library and guides - Rename list_webhooks/list_integrations to browse_webhooks/browse_integrations - Remove per_page from non-paginated actions (get, job, logs, content, compare) - Remove ref from manage_pipeline retry/cancel (only needed for create) - Fix manage_ref params: delete_branch uses branch only, protect_branch uses name, create_tag uses tag_name+ref - Use limit instead of per_page for job logs - Update manage_webhook actions (remove 'read'), manage_integration (remove 'get') * fix(docs): correct tool action params and restore CLI nav - browse_webhooks: use action "list" instead of empty string - browse_integrations: add required action "list" field - create_branch: remove action field from standalone tool usage - manage_ref protect_branch: use name param, remove branch/ref - manage_ref create_tag: remove invalid branch param - browse_milestones: use "issues" action for milestone filtering - Restore CLI nav link in config.mts - Rename "Related Guides" section to "Related" where mixed * fix(docs): add CLI sidebar section for /cli/ pages * fix(docs): remove invalid params from tool examples - list_group_iterations: remove action field (flat schema, no action) - browse_work_items get: remove state/first/simple (list-only params) - manage_merge_request approve/merge/update/get_approval_state: remove source_branch (not accepted by these actions) - manage_work_item update: remove namespace/workItemType (only id accepted) * fix(docs): correct publish_all, webhook, and integration examples - manage_draft_notes publish_all: remove invalid note field - manage_webhook description: replace CRUD with actual actions - manage_integration description: remove get (moved to browse_integrations) - setup-ci-notifications: use browse_integrations for get action * fix(docs): clarify tool references and descriptions - project-management: add missing list_project_members tool to table - repository: reorder create_branch as convenience alias of manage_ref - debug-failure: add tool name comments to distinguish manage_pipeline from manage_pipeline_job - multi-gitlab-setup: fix reset description (restores full context, not just scope) * fix(docs): use jsonc lang tag, correct API URL and tool schemas - Switch fenced blocks with // annotations to jsonc language - GITLAB_API_URL: use base URL without /api/v4 suffix - manage_ref: add update_branch_protection and unprotect_tag actions - create_branch: standalone tool, not alias - project-management: label browse_members example explicitly * fix(docs): add missing access levels and use conventional commits - Access level tables: add 0=No access and 5=Minimal - Suggestion examples: use conventional commit format in commit_message * fix(docs): add missing draft action and use conventional commit format * fix(availability): optimize instance info lookup and refresh cache after init - Accept optional pre-fetched instanceInfo in getRestrictedParameters() to avoid redundant ConnectionManager calls per tool in cache build loop - Pre-fetch instance info once at the start of buildToolLookupCache() - Refresh registry cache after successful ConnectionManager initialization so tier-restricted parameters are stripped even if RegistryManager was constructed before connection was available - Invalidate toolDefinitionsCache in refreshCache() for consistency * fix(availability): guard parameter stripping when connection unavailable - Skip getRestrictedParameters call when instanceInfo is undefined to avoid repeated throw/catch in ConnectionManager.getInstanceInfo() - Use invalidateCaches() in refreshCache() to clear all derived caches (toolNamesCache, readOnlyToolsCache) consistently - Add ConnectionManager mock to RegistryManager and ToolDescriptionOverrides tests so buildToolLookupCache can pre-fetch instance info * test(availability): cover cachedInstanceInfo branch and uninitialized guard - Add tests for getRestrictedParameters with cachedInstanceInfo parameter (tier check, version check, bypassing ConnectionManager) - Add RegistryManager test verifying parameter stripping is skipped when ConnectionManager is not initialized * fix(schema-utils): always filter required array in stripFromProperties - Restructure stripFromProperties to handle required filtering independently from properties deletion - Add mockClear() in RegistryManager test to prevent stale call history - Add test case for schema with required but no properties object
1 parent 1e34a93 commit 1ae98c8

File tree

8 files changed

+621
-3
lines changed

8 files changed

+621
-3
lines changed

src/handlers.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,10 @@ export async function setupHandlers(server: Server): Promise<void> {
305305

306306
const instanceInfo = connectionManager.getInstanceInfo();
307307
logger.info(`Connection initialized: ${instanceInfo.version} ${instanceInfo.tier}`);
308+
309+
// Rebuild registry cache now that tier/version info is available
310+
const { RegistryManager } = await import("./registry-manager");
311+
RegistryManager.getInstance().refreshCache();
308312
} catch (initError) {
309313
logger.error(
310314
`Connection initialization failed: ${initError instanceof Error ? initError.message : String(initError)}`

src/registry-manager.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,12 @@ import {
5252
getToolDescriptionOverrides,
5353
} from "./config";
5454
import { ToolAvailability } from "./services/ToolAvailability";
55+
import { ConnectionManager } from "./services/ConnectionManager";
56+
import type { GitLabTier } from "./services/GitLabVersionDetector";
5557
import { logger } from "./logger";
5658
import {
5759
transformToolSchema,
60+
stripTierRestrictedParameters,
5861
shouldRemoveTool,
5962
extractActionsFromSchema,
6063
} from "./utils/schema-utils";
@@ -269,6 +272,15 @@ class RegistryManager {
269272
private buildToolLookupCache(): void {
270273
this.toolLookupCache.clear();
271274

275+
// Pre-fetch instance info once per cache build to avoid redundant calls
276+
let instanceInfo: { tier: GitLabTier; version: string } | undefined;
277+
try {
278+
const info = ConnectionManager.getInstance().getInstanceInfo();
279+
instanceInfo = { tier: info.tier, version: info.version };
280+
} catch {
281+
// Connection not initialized - parameter restrictions won't apply
282+
}
283+
272284
for (const registry of this.registries.values()) {
273285
for (const [toolName, tool] of registry) {
274286
// Apply GITLAB_READ_ONLY_MODE filtering at registry level
@@ -301,7 +313,15 @@ class RegistryManager {
301313
let finalTool = tool;
302314

303315
// Transform schema to remove denied actions and apply description overrides
304-
const transformedSchema = transformToolSchema(toolName, tool.inputSchema);
316+
let transformedSchema = transformToolSchema(toolName, tool.inputSchema);
317+
318+
// Strip tier-restricted parameters from schema (skip if connection not initialized)
319+
if (instanceInfo) {
320+
const restrictedParams = ToolAvailability.getRestrictedParameters(toolName, instanceInfo);
321+
if (restrictedParams.length > 0) {
322+
transformedSchema = stripTierRestrictedParameters(transformedSchema, restrictedParams);
323+
}
324+
}
305325

306326
// Apply tool-level description override if available
307327
const customDescription = this.descriptionOverrides.get(toolName);
@@ -356,10 +376,10 @@ class RegistryManager {
356376
}
357377

358378
/**
359-
* Clear all caches and rebuild
379+
* Clear all caches and rebuild (e.g., after ConnectionManager init provides tier/version)
360380
*/
361381
public refreshCache(): void {
362-
this.buildToolLookupCache();
382+
this.invalidateCaches();
363383
}
364384

365385
/**

src/services/ToolAvailability.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ interface ActionRequirement {
1818
notes?: string;
1919
}
2020

21+
/**
22+
* Parameter-level tier/version requirement.
23+
* Same structure as ActionRequirement; aliased for semantic clarity
24+
* when used in parameterRequirements map.
25+
*/
26+
type ParameterRequirement = ActionRequirement;
27+
2128
/**
2229
* Tool with action-level requirements (for consolidated tools)
2330
*/
@@ -726,6 +733,80 @@ export class ToolAvailability {
726733
},
727734
};
728735

736+
// ============================================================================
737+
// Per-Parameter Tier Requirements
738+
// ============================================================================
739+
740+
/**
741+
* Parameter-level requirements for tools with tier-gated parameters.
742+
* Parameters listed here will be stripped from the JSON Schema when the
743+
* detected instance tier/version is insufficient.
744+
*/
745+
private static parameterRequirements: Record<string, Record<string, ParameterRequirement>> = {
746+
manage_work_item: {
747+
weight: { tier: "premium", minVersion: "15.0", notes: "Work item weight widget" },
748+
iterationId: { tier: "premium", minVersion: "15.0", notes: "Iteration widget" },
749+
healthStatus: { tier: "ultimate", minVersion: "15.0", notes: "Health status widget" },
750+
},
751+
};
752+
753+
/**
754+
* Get list of parameter names that should be REMOVED from the schema
755+
* for the current instance tier and version.
756+
*
757+
* @param toolName - Tool name to check parameter requirements for
758+
* @returns Array of parameter names that should be stripped from the schema
759+
*/
760+
public static getRestrictedParameters(
761+
toolName: string,
762+
cachedInstanceInfo?: { tier: GitLabTier; version: string }
763+
): string[] {
764+
const paramReqs = this.parameterRequirements[toolName];
765+
if (!paramReqs) return [];
766+
767+
let instanceTier: GitLabTier;
768+
let instanceVersion: number;
769+
let rawVersion: string;
770+
771+
if (cachedInstanceInfo) {
772+
instanceTier = cachedInstanceInfo.tier;
773+
rawVersion = cachedInstanceInfo.version;
774+
instanceVersion = parseVersion(rawVersion);
775+
} else {
776+
const connectionManager = ConnectionManager.getInstance();
777+
try {
778+
const instanceInfo = connectionManager.getInstanceInfo();
779+
instanceTier = instanceInfo.tier;
780+
rawVersion = instanceInfo.version;
781+
instanceVersion = parseVersion(rawVersion);
782+
} catch {
783+
// Connection not initialized - don't restrict anything
784+
return [];
785+
}
786+
}
787+
788+
const restricted: string[] = [];
789+
const actualTierLevel = this.TIER_ORDER[instanceTier] ?? 0;
790+
791+
for (const [paramName, req] of Object.entries(paramReqs)) {
792+
const requiredTierLevel = this.TIER_ORDER[req.tier] ?? 0;
793+
const requiredVersion = parseVersion(req.minVersion);
794+
795+
// Parameter is restricted if tier is insufficient OR version is too low
796+
if (actualTierLevel < requiredTierLevel || instanceVersion < requiredVersion) {
797+
restricted.push(paramName);
798+
}
799+
}
800+
801+
if (restricted.length > 0) {
802+
logger.debug(
803+
`Tool '${toolName}': restricted parameters for tier=${instanceTier}, version=${rawVersion}: [${restricted.join(", ")}]`
804+
);
805+
}
806+
807+
return restricted;
808+
}
809+
729810
/**
730811
* Get requirement for a tool, optionally with action
731812
*/

src/utils/schema-utils.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,63 @@ function filterFlatSchemaActions(schema: JSONSchema, toolName: string): JSONSche
431431
return result;
432432
}
433433

434+
// ============================================================================
435+
// Parameter-Level Tier Filtering
436+
// ============================================================================
437+
438+
/**
439+
* Strip tier-restricted parameters from a JSON Schema.
440+
* Removes properties and their required entries for parameters that are
441+
* unavailable at the current GitLab instance tier/version.
442+
*
443+
* Works with both flat schemas and discriminated unions (oneOf).
444+
*
445+
* @param schema - JSON schema (already transformed by the main pipeline)
446+
* @param restrictedParams - Parameter names to remove from the schema
447+
* @returns Schema with restricted parameters removed
448+
*/
449+
export function stripTierRestrictedParameters(
450+
schema: JSONSchema,
451+
restrictedParams: string[]
452+
): JSONSchema {
453+
if (restrictedParams.length === 0) {
454+
return schema;
455+
}
456+
457+
// Deep clone to avoid mutating original
458+
const result = JSON.parse(JSON.stringify(schema)) as JSONSchema;
459+
const restrictedSet = new Set(restrictedParams);
460+
461+
if (result.oneOf) {
462+
// Discriminated union: strip from each branch
463+
for (const branch of result.oneOf) {
464+
stripFromProperties(branch, restrictedSet);
465+
}
466+
} else {
467+
// Flat schema: strip from top-level properties
468+
stripFromProperties(result, restrictedSet);
469+
}
470+
471+
return result;
472+
}
473+
474+
/**
475+
* Remove restricted parameters from a schema object's properties and required array
476+
*/
477+
function stripFromProperties(schema: JSONSchema, restrictedParams: Set<string>): void {
478+
if (schema.properties) {
479+
for (const paramName of restrictedParams) {
480+
if (paramName in schema.properties) {
481+
delete schema.properties[paramName];
482+
}
483+
}
484+
}
485+
486+
if (schema.required) {
487+
schema.required = schema.required.filter(name => !restrictedParams.has(name));
488+
}
489+
}
490+
434491
// ============================================================================
435492
// Utility Functions
436493
// ============================================================================

tests/unit/RegistryManager.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,15 @@ jest.mock("../../src/services/ToolAvailability", () => ({
7272
ToolAvailability: {
7373
isToolAvailable: jest.fn(),
7474
getUnavailableReason: jest.fn(),
75+
getRestrictedParameters: jest.fn().mockReturnValue([]),
76+
},
77+
}));
78+
79+
jest.mock("../../src/services/ConnectionManager", () => ({
80+
ConnectionManager: {
81+
getInstance: jest.fn().mockReturnValue({
82+
getInstanceInfo: jest.fn().mockReturnValue({ tier: "free", version: "17.0.0" }),
83+
}),
7584
},
7685
}));
7786

@@ -322,6 +331,114 @@ describe("RegistryManager", () => {
322331
expect(names).toContain("core_tool_1");
323332
expect(names).not.toContain("unavailable_tool");
324333
});
334+
335+
it("should strip tier-restricted parameters from tool schema", () => {
336+
// Add a tool with properties to test parameter stripping
337+
const coreRegistry = require("../../src/entities/core/registry").coreToolRegistry;
338+
coreRegistry.set("tool_with_params", {
339+
name: "tool_with_params",
340+
description: "Tool with tier-gated params",
341+
inputSchema: {
342+
type: "object",
343+
properties: {
344+
action: { type: "string", enum: ["create"] },
345+
title: { type: "string" },
346+
weight: { type: "number", description: "Premium param" },
347+
healthStatus: { type: "string", description: "Ultimate param" },
348+
},
349+
required: ["action", "title", "weight"],
350+
},
351+
handler: jest.fn(),
352+
});
353+
354+
try {
355+
// Make getRestrictedParameters return restricted params for this tool
356+
ToolAvailability.getRestrictedParameters.mockImplementation((toolName: string) =>
357+
toolName === "tool_with_params" ? ["weight", "healthStatus"] : []
358+
);
359+
360+
(RegistryManager as any).instance = null;
361+
registryManager = RegistryManager.getInstance();
362+
363+
const tool = registryManager.getTool("tool_with_params");
364+
expect(tool).toBeDefined();
365+
366+
const schema = tool?.inputSchema as any;
367+
368+
// Restricted properties should be removed
369+
expect(schema.properties?.weight).toBeUndefined();
370+
expect(schema.properties?.healthStatus).toBeUndefined();
371+
372+
// Non-restricted properties should remain
373+
expect(schema.properties?.action).toBeDefined();
374+
expect(schema.properties?.title).toBeDefined();
375+
376+
// weight was in required, should be removed
377+
expect(schema.required).not.toContain("weight");
378+
expect(schema.required).toContain("action");
379+
expect(schema.required).toContain("title");
380+
} finally {
381+
coreRegistry.delete("tool_with_params");
382+
}
383+
});
384+
385+
it("should skip parameter stripping when ConnectionManager is not initialized", () => {
386+
const { ConnectionManager } = require("../../src/services/ConnectionManager");
387+
const coreRegistry = require("../../src/entities/core/registry").coreToolRegistry;
388+
389+
coreRegistry.set("tool_with_params", {
390+
name: "tool_with_params",
391+
description: "Tool with tier-gated params",
392+
inputSchema: {
393+
type: "object",
394+
properties: {
395+
weight: { type: "number" },
396+
title: { type: "string" },
397+
},
398+
required: ["weight", "title"],
399+
},
400+
handler: jest.fn(),
401+
});
402+
403+
try {
404+
// Mock getRestrictedParameters to return restricted params (would strip if called)
405+
ToolAvailability.getRestrictedParameters.mockReturnValue(["weight"]);
406+
407+
// Make ConnectionManager throw (simulating uninitialized connection)
408+
ConnectionManager.getInstance.mockReturnValue({
409+
getInstanceInfo: jest.fn().mockImplementation(() => {
410+
throw new Error("Connection not initialized");
411+
}),
412+
});
413+
414+
// Clear call history from beforeEach cache build before creating new instance
415+
ToolAvailability.getRestrictedParameters.mockClear();
416+
417+
(RegistryManager as any).instance = null;
418+
registryManager = RegistryManager.getInstance();
419+
420+
const tool = registryManager.getTool("tool_with_params");
421+
expect(tool).toBeDefined();
422+
423+
const schema = tool?.inputSchema as any;
424+
425+
// Parameters should NOT be stripped when connection is unavailable
426+
// (getRestrictedParameters should not be called at all)
427+
expect(schema.properties?.weight).toBeDefined();
428+
expect(schema.properties?.title).toBeDefined();
429+
expect(schema.required).toContain("weight");
430+
431+
// Verify getRestrictedParameters was NOT called (guard prevented it)
432+
expect(ToolAvailability.getRestrictedParameters).not.toHaveBeenCalled();
433+
} finally {
434+
coreRegistry.delete("tool_with_params");
435+
ToolAvailability.getRestrictedParameters.mockReturnValue([]);
436+
// Restore ConnectionManager mock
437+
ConnectionManager.getInstance.mockReturnValue({
438+
getInstanceInfo: jest.fn().mockReturnValue({ tier: "free", version: "17.0.0" }),
439+
});
440+
}
441+
});
325442
});
326443

327444
describe("Description Overrides", () => {

0 commit comments

Comments
 (0)