From 281d182a15aca4b91c6d0a7f6019ee5996e91ab7 Mon Sep 17 00:00:00 2001 From: Achintya Ashok Date: Sun, 28 Sep 2025 17:33:22 -0400 Subject: [PATCH] Restore real MCP configs and add stub-driven integration harnesses --- docs/features/prd.tool-alias-support.md | 239 ++++++++++++++++++ src/server/enhanced.test.ts | 28 +- .../tools/config-tools/tools/build-toolset.ts | 10 +- .../tools/list-available-tools.ts | 5 + src/server/tools/schemas.ts | 8 + src/server/tools/toolset/loader.test.ts | 18 ++ src/server/tools/toolset/loader.ts | 30 ++- src/server/tools/toolset/manager.test.ts | 56 +++- src/server/tools/toolset/manager.ts | 160 ++++++++++-- src/server/tools/toolset/types.ts | 2 + src/server/tools/toolset/validator.test.ts | 61 +++++ src/server/tools/toolset/validator.ts | 84 ++++++ src/test-utils/test-config-tools.sh | 117 ++++++++- .../test-persona-toolset-activation.sh | 86 ++++++- src/test-utils/test-tool-alias-routing.sh | 200 +++++++++++++++ test/stub-servers/mcp-stub.mjs | 224 ++++++++++++++++ 16 files changed, 1286 insertions(+), 42 deletions(-) create mode 100644 docs/features/prd.tool-alias-support.md create mode 100755 src/test-utils/test-tool-alias-routing.sh create mode 100644 test/stub-servers/mcp-stub.mjs diff --git a/docs/features/prd.tool-alias-support.md b/docs/features/prd.tool-alias-support.md new file mode 100644 index 0000000..bb5369a --- /dev/null +++ b/docs/features/prd.tool-alias-support.md @@ -0,0 +1,239 @@ +# Tool Alias Support for Standard Toolsets + +**Created**: 2025-02-14 +**Author**: ChatGPT-5 (autonomous session) +**Reference Commit**: 015aa596b576983009fafe562cb3987cb8f03306 +**Branch**: work +**Related Tasks**: TBD +**Status**: Draft +**Priority**: P1 (High) + +## Executive Summary + +Users of the standard tool system can currently equip tools only under the names exposed by their source MCP servers. This limits their ability to design human-friendly workflows, reduce tool name collisions, and maintain consistency across environments. We propose adding alias support to toolsets so that the standard configuration pipeline—rooted in the `build-toolset` configuration tool—can resolve user-defined aliases to the canonical tool definitions supplied by MCP servers. This will reduce friction for multi-server setups and enable more intuitive command vocabularies without modifying downstream servers. + +## Problem Statement + +### What is broken or missing +- Toolsets today accept the server-provided tool name verbatim and expose it directly to LLM agents. +- There is no built-in mechanism to define a custom name or shorthand for an existing tool. + +### Who is affected +- Hypertool users configuring standard toolsets in the manager UI or JSON definitions. +- Developers integrating multiple MCP servers with overlapping tool namespaces. +- LLM agents that must reason about verbose or conflicting tool names. + +### Why it needs to be fixed +- Without aliases, administrators cannot tailor tool names to their workflow jargon or shorten verbose server names. +- Tool name collisions between servers (e.g., multiple `search` tools) make selection ambiguous. +- LLM prompts must include the original names, reducing accuracy when users expect friendlier handles. + +### Current vs expected behavior +- **Current**: Toolsets only expose canonical tool identifiers. Any name collision must be resolved by editing upstream servers or avoiding a tool. +- **Expected**: Toolset authors can assign alias names per toolset. When an alias is invoked, the manager resolves it to the original tool reference without modifying server metadata. + +## Goals & Non-Goals + +### Goals +- Allow standard toolset configurations to declare alias names for tools they include. +- Ensure aliases are respected uniformly across tool discovery, invocation, and reflection logs. +- Provide users with validation and conflict detection for alias declarations. +- Surface alias information to LLMs so prompt planners understand both the alias and canonical name. + +### Non-Goals +- No changes to the persona system (handled separately). +- No dynamic alias editing via conversation in this phase; configuration-driven only. +- No guarantee that third-party MCP servers adopt alias awareness; resolution stays within Hypertool manager. + +## User Stories + +1. **As a toolset administrator**, I want to assign a short alias (`git_status`) to a verbose MCP tool name so that LLMs can invoke it succinctly. +2. **As an LLM operator**, I want to differentiate between two `search` tools from different servers by aliasing them `web_search` and `docs_search`, preventing accidental misuse. +3. **As a developer**, I want validation errors if I define the same alias twice or collide with an existing canonical name. + +## Proposed Solution + +### Overview +Extend the standard toolset configuration schema and manager logic to support alias metadata for each included tool. During toolset hydration, the manager will map alias names to canonical tool references, ensuring invocation requests using either name resolve correctly. Discovery responses will include both the alias and canonical name to aid LLM selection. Because the standard system does not expose a separate CLI for editing stored toolsets, alias assignment will be available exclusively through the existing `build-toolset` configuration tool flow. + +### Architecture & Component Impact + +The changes touch the configuration tool pipeline described in `src/server/tools/CLAUDE.md` and `src/server/tools/config-tools/CLAUDE.md`, which route all standard toolset management through `ConfigToolsManager` and `ToolsetManager`. Key components and functions requiring updates are: + +- **Config Tools surface** (`src/server/tools/config-tools/tools/build-toolset.ts`) + - Extend the JSON schema accepted by the `build-toolset` tool to allow an optional `alias` field per `DynamicToolReference` entry. + - Ensure handler passes alias metadata through to `ToolsetManager.buildToolset`. +- **ToolsetManager** (`src/server/tools/toolset/manager.ts`) + - `buildToolset(...)`: Persist alias metadata alongside canonical references, validate naming and collision rules, and emit descriptive errors when aliases conflict with canonical names or each other. + - `getMcpTools()` / `_getToolFromDiscoveredTool(...)`: Include alias data when shaping MCP tool descriptors so LLM-facing prompts contain both alias and canonical names. + - `getOriginalToolName(...)`: Resolve from alias → canonical tool reference before delegating to the request router. + - Internal helpers such as `validateToolReferences(...)` and `generateToolsetInfo(...)`: surface alias context in structured responses, telemetry, and saved toolset metadata. +- **EnhancedServer request routing** (`src/server/enhanced.ts`) + - Calls to `toolsetManager.getOriginalToolName(name)` must consider alias mappings to ensure invocation goes to the correct downstream server even when the alias is supplied. +- **Preference Store Serialization** (`src/server/config/preferenceStore.ts` and related loaders/validators) + - Update serialization/deserialization to persist alias strings, ensuring round-trip through disk works without data loss. +- **Schema Definitions** (`src/server/tools/toolset/types.ts`, `validator.ts`, and `src/server/tools/schemas.ts`) + - Introduce `alias?: string` on stored tool references and expand validation to enforce casing, length, and uniqueness constraints. + +#### Process Flow Diagram + +```mermaid +flowchart LR + subgraph ConfigTools + BT[build-toolset tool] + CTM[ConfigToolsManager] + end + subgraph ToolsetRuntime + TM[ToolsetManager] + PS[Preference Store] + end + subgraph Execution + ES[EnhancedServer] + RR[RequestRouter] + DS[Downstream MCP Server] + end + + BT -->|alias + canonical refs| CTM -->|delegate| TM + TM -->|validate & persist| PS + ES -->|tool discovery| TM + ES -->|tool call (alias)| TM + TM -->|canonical name| ES --> RR --> DS + TM -->|alias + canonical metadata| ES +``` + +This flow highlights that alias assignment enters the system exclusively through the `build-toolset` call and is resolved by `ToolsetManager` before routing to downstream servers. + +### Technical Design + +#### Schema Updates +- Update `ToolsetToolConfig` (and the runtime `DynamicToolReference` type in `src/server/tools/toolset/types.ts`) to optionally include an `alias` field (`string`, lowercase, snake_case enforced). +- Persist alias metadata when writing toolsets to the preference store (`saveStoredToolsets`) and when reloading them via `loadToolsetConfig` so aliases survive restart cycles. +- Expand the `build-toolset` tool's input schema to accept `alias` alongside `namespacedName`/`refId`, noting that alias assignment happens only inside this configuration tool flow. + +#### Toolset Manager Enhancements +- When equipping or loading a toolset, build an in-memory alias registry keyed by alias name storing `{ alias, canonicalName, serverId, refId }` for O(1) lookup inside `getOriginalToolName` and related routing helpers. +- Validate aliases inside `buildToolset` and any load path: + - Reject duplicates within the same toolset and raise actionable `meta.error` messages back through the `build-toolset` response payload. + - Reject aliases equal to an existing canonical name unless they map to that exact tool, preventing accidental shadowing of different tools. + - Enforce naming conventions via the validator (`^[a-z0-9_:-]{2,64}$`, final pattern to be agreed) and reuse validation in both CLI-driven and config-file load paths. +- Modify tool discovery output (`getMcpTools` + `_hydrateToolNotes`) to include alias metadata. Recommended approach: append alias detail to tool description and extend structured payloads (e.g., `ToolInfoResponse`) with an `alias` field so LLM prompts and logs highlight the mapping. +- Update invocation routing so that when a call references an alias, the manager translates it to the underlying tool reference before dispatching to the MCP transport, ensuring parity for alias and canonical names. + +#### Config Tools & LLM Exposure +- Update tool descriptors passed to LLMs to annotate alias usage, e.g., `Alias: web_search (maps to linear.search)` in the prompt context emitted by `ToolsetManager.getMcpTools`. +- Ensure transcripts/logs capture both alias and canonical name to aid debugging. +- Extend `list-saved-toolsets`, `equip-toolset`, and `get-active-toolset` responses to surface alias metadata so users can verify configurations via configuration mode. + +#### API / CLI Touchpoints +- The only supported alias entry point is the `build-toolset` tool invoked through configuration mode (either manually or via automation). Update in-product documentation and help text to describe the new `alias` argument. +- If we later expose helper commands in scripts or UI, they must internally call `build-toolset` with the alias metadata to stay consistent with the single source of truth. +- JSON schema validation (if exposed) must document the new field with examples. + +### Code Locations +- **Primary Files**: + - `src/server/tools/toolset/manager.ts` (alias registry, routing, metadata exposure) + - `src/server/tools/toolset/types.ts` and `validator.ts` (schema updates) + - `src/server/tools/config-tools/tools/build-toolset.ts` (input schema and handler passthrough) + - `src/server/tools/schemas.ts` (JSON schema surfaced to clients) + - `src/server/enhanced.ts` (request routing via alias-aware lookup) + - `src/server/config/preferenceStore.ts` (persisting alias data) +- **Support Files**: Update toolset loader/persistence helpers and any developer documentation describing toolset JSON structure. +- **New Files**: Optional migration helper for existing stored toolsets (`scripts/migrations/add-alias-defaults.ts`) if we need to backfill alias fields safely. +- **Test Files**: Extend existing suites under `src/server/tools/toolset/*.test.ts` and add focused alias tests for configuration tools and enhanced server routing. + +### Implementation Plan +1. **Phase 1**: Update schemas, TypeScript interfaces, and validation utilities to accept the alias field. Add unit tests covering validation rules and preference store serialization. +2. **Phase 2**: Extend toolset manager resolution logic and discovery responses to include alias metadata. Update LLM prompt formatting and ensure `EnhancedServer` routing logic resolves aliases. +3. **Phase 3**: Update configuration tools (input schema, help text), write integration tests for end-to-end alias invocation, and migrate existing toolsets (no-op if alias omitted). + +## Alternative Solutions Considered + +### Option 1: Server-Side Aliases +- **Description**: Require MCP servers to expose configurable alias metadata. +- **Pros**: Centralizes alias logic at the source. +- **Cons**: Requires coordination with each server; breaks for third-party servers lacking support. +- **Reason Not Chosen**: Hypertool needs client-side flexibility without waiting for server adoption. + +### Option 2: Prompt-Time Alias Mapping Only +- **Description**: Only modify LLM prompts to suggest alias usage without changing invocation routing. +- **Pros**: Minimal engineering effort. +- **Cons**: Does not solve invocation routing; LLM invocations would still use canonical names. +- **Reason Not Chosen**: Fails to remove ambiguity or allow actual alias-based commands. + +## Testing Requirements + +### Unit Tests +- Extend `src/server/tools/toolset/validator.test.ts` with alias-specific validation cases (valid formats, duplicates, canonical conflicts). +- Add `manager.aliases.test.ts` (new) covering alias registry construction, `getOriginalToolName` lookups, and `getMcpTools` metadata exposure. +- Update `src/server/tools/config-tools/tools/build-toolset.test.ts` (create if missing) to ensure handler forwards alias arguments and surfaces validation errors from the manager. + +### Integration Tests +- Extend `src/server/enhanced.test.ts` (or add `enhanced.aliases.test.ts`) to simulate an alias-based tool call and ensure routing to the correct downstream server. +- Update shell-based test scripts in `src/test-utils/` (e.g., extend `test-config-tools.sh`) to build a toolset with aliases via the `build-toolset` tool and verify the alias appears in `list-saved-toolsets`. +- Add regression coverage ensuring reloading stored toolsets with aliases rehydrates alias metadata correctly (persist → restart → equip → call). + +### Manual Testing +- Configure a sample toolset via configuration mode using the `build-toolset` tool, supplying aliases for multiple tools. Verify alias usage in prompts and logs during a simulated LLM session. +- Attempt to assign invalid aliases (uppercase characters, duplicates, collision with canonical names) and observe descriptive `build-toolset` failure responses. +- Restart the server, re-enter configuration mode, and confirm `list-saved-toolsets` surfaces alias metadata, proving persistence. + +## Impact Analysis + +### Breaking Changes +None. Toolsets without aliases continue to operate unchanged. + +### Performance Impact +Minimal. Alias lookup adds an in-memory map lookup per invocation, negligible relative to MCP calls. + +### Security Considerations +- Ensure alias validation prevents injection of malicious text into prompts or logs. +- Audit logging should capture canonical tool identifiers for traceability even when aliases are used. + +### Compatibility +- Backward compatible with existing toolset configurations (alias field optional). +- Forward compatible for future UI enhancements showing multiple aliases per tool. + +## Success Criteria + +1. Users can define an alias in a toolset configuration (via `build-toolset`) and invoke the tool via the alias in a live session. +2. Tool discovery outputs display alias information alongside canonical names. +3. Validation prevents duplicate or conflicting alias names within a toolset and communicates errors through the configuration tool response payloads. + +## Rollout Plan + +### Phase 1: Development +- Timeline: 2 weeks +- Resources: 1 engineer familiar with toolset manager and CLI components. + +### Phase 2: Testing +- Timeline: 1 week +- Resources: QA engineer plus developer for integration validation. + +### Phase 3: Deployment +- Timeline: 1 week +- Deployment strategy: Standard release train with feature flag toggle if necessary. +- Rollback plan: Disable alias resolution code path via configuration flag; existing canonical names remain functional. + +## Documentation Updates + +- [ ] Update README +- [ ] Update API documentation +- [ ] Update user guides (toolset configuration section) +- [ ] Update developer documentation (schema reference) +- [ ] Update CHANGELOG + +## Open Questions + +1. Should we allow multiple aliases per tool in the initial release? +2. How should aliases appear in telemetry dashboards and analytics? + +## References + +- Existing toolset manager code documentation. +- MCP specification for tool naming conventions. + +## Revision History + +| Date | Author | Changes | +|------|--------|---------| +| 2025-02-14 | ChatGPT-5 | Initial draft | diff --git a/src/server/enhanced.test.ts b/src/server/enhanced.test.ts index 2c1479c..2a8ff96 100644 --- a/src/server/enhanced.test.ts +++ b/src/server/enhanced.test.ts @@ -2,7 +2,7 @@ * Unit tests for Enhanced Hypertool MCP server */ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { EnhancedMetaMCPServer } from "./enhanced.js"; import { MetaMCPServerConfig, ServerState } from "./types.js"; import { ServerConfig } from "../config/index.js"; @@ -166,4 +166,30 @@ describe("EnhancedMetaMCPServer", () => { expect(Object.keys(filtered)).toHaveLength(0); }); }); + + describe("Alias-aware tool routing", () => { + it("should translate aliases to canonical names before routing", async () => { + const routeToolCall = vi + .fn() + .mockResolvedValue({ result: "ok", isError: false }); + (server as any).requestRouter = { routeToolCall }; + + const toolsetManager = (server as any).toolsetManager; + const getOriginalToolNameSpy = vi + .spyOn(toolsetManager, "getOriginalToolName") + .mockReturnValue("git.status"); + + const response = await (server as any).handleToolCall( + "git_status", + {} + ); + + expect(getOriginalToolNameSpy).toHaveBeenCalledWith("git_status"); + expect(routeToolCall).toHaveBeenCalledWith({ + name: "git.status", + arguments: {}, + }); + expect(response).toEqual({ result: "ok", isError: false }); + }); + }); }); diff --git a/src/server/tools/config-tools/tools/build-toolset.ts b/src/server/tools/config-tools/tools/build-toolset.ts index 2ba5661..3fffe54 100644 --- a/src/server/tools/config-tools/tools/build-toolset.ts +++ b/src/server/tools/config-tools/tools/build-toolset.ts @@ -24,7 +24,7 @@ export const buildToolsetDefinition: Tool = { tools: { type: "array", description: - "Array of tools to include in the toolset. Each tool must specify either namespacedName or refId for identification. Use list-available-tools to see available options.", + "Array of tools to include in the toolset. Each tool must specify either namespacedName or refId for identification. Optionally provide an alias to expose the tool under a custom snake_case name. Use list-available-tools to see available options.", minItems: 1, maxItems: 100, items: { @@ -40,6 +40,14 @@ export const buildToolsetDefinition: Tool = { description: "Tool reference by unique hash identifier (e.g., 'abc123def456...')", }, + alias: { + type: "string", + description: + "Optional alias to expose the tool under a custom snake_case name (e.g., 'git_status' or 'docs_search')", + pattern: "^[a-z0-9_]+$", + minLength: 2, + maxLength: 50, + }, }, oneOf: [{ required: ["namespacedName"] }, { required: ["refId"] }], additionalProperties: false, diff --git a/src/server/tools/config-tools/tools/list-available-tools.ts b/src/server/tools/config-tools/tools/list-available-tools.ts index 5ff94eb..d6772e8 100644 --- a/src/server/tools/config-tools/tools/list-available-tools.ts +++ b/src/server/tools/config-tools/tools/list-available-tools.ts @@ -73,6 +73,11 @@ export const listAvailableToolsDefinition: Tool = { type: "string", description: "Unique hash identifier for this tool", }, + alias: { + type: "string", + description: + "Optional alias assigned within the currently equipped toolset", + }, }, required: ["name", "namespacedName", "serverName", "refId"], }, diff --git a/src/server/tools/schemas.ts b/src/server/tools/schemas.ts index 07f3410..f658e43 100644 --- a/src/server/tools/schemas.ts +++ b/src/server/tools/schemas.ts @@ -26,6 +26,10 @@ export const toolInfoResponseZodSchema = z.object({ .describe("Namespaced tool name (e.g., 'git.status')"), serverName: z.string().describe("Server that provides this tool"), description: z.string().optional().describe("Tool description"), + alias: z + .string() + .optional() + .describe("Alias assigned to this tool within the equipped toolset"), context: contextInfoZodSchema.describe( "Context usage information for this tool" ), @@ -52,6 +56,10 @@ export const toolsetToolRefZodSchema = z.object({ active: z .boolean() .describe("Whether this tool is currently available/active"), + alias: z + .string() + .optional() + .describe("Alias assigned to this tool within the toolset, if any"), }); /** diff --git a/src/server/tools/toolset/loader.test.ts b/src/server/tools/toolset/loader.test.ts index 9840592..e8b29b0 100644 --- a/src/server/tools/toolset/loader.test.ts +++ b/src/server/tools/toolset/loader.test.ts @@ -98,6 +98,24 @@ describe("ToolsetLoader", () => { expect(result.config!.createdAt).toBeInstanceOf(Date); // Default date added }); + it("should preserve and trim tool aliases", async () => { + const rawConfig = { + name: "aliased-config", + tools: [ + { namespacedName: "git.status", alias: " git_status " }, + { refId: "hash123456789", alias: "docker_ps" }, + ], + }; + + const filePath = path.join(tempDir, "aliased.json"); + await fs.writeFile(filePath, JSON.stringify(rawConfig)); + + const result = await loadToolsetConfig(filePath); + expect(result.config).toBeDefined(); + expect(result.config!.tools[0].alias).toBe("git_status"); + expect(result.config!.tools[1].alias).toBe("docker_ps"); + }); + it("should filter out invalid tool references", async () => { const configWithInvalidTools = { name: "mixed-tools", diff --git a/src/server/tools/toolset/loader.ts b/src/server/tools/toolset/loader.ts index 00c586e..4df985d 100644 --- a/src/server/tools/toolset/loader.ts +++ b/src/server/tools/toolset/loader.ts @@ -8,6 +8,7 @@ import { ToolsetConfig, ToolsetParserOptions, ValidationResult, + DynamicToolReference, } from "./types.js"; import { validateToolsetConfig } from "./validator.js"; @@ -141,12 +142,29 @@ function normalizeToolsetConfig(rawConfig: any): ToolsetConfig { // Normalize tools array if (Array.isArray(rawConfig.tools)) { - config.tools = rawConfig.tools.filter((tool: any) => { - return ( - (tool.namespacedName && typeof tool.namespacedName === "string") || - (tool.refId && typeof tool.refId === "string") - ); - }); + config.tools = rawConfig.tools + .filter((tool: any) => { + return ( + (tool.namespacedName && typeof tool.namespacedName === "string") || + (tool.refId && typeof tool.refId === "string") + ); + }) + .map((tool: any) => { + const normalized: DynamicToolReference = { + namespacedName: + typeof tool.namespacedName === "string" + ? tool.namespacedName + : undefined, + refId: + typeof tool.refId === "string" ? tool.refId : undefined, + }; + + if (typeof tool.alias === "string" && tool.alias.trim().length > 0) { + normalized.alias = tool.alias.trim(); + } + + return normalized; + }); } return config; diff --git a/src/server/tools/toolset/manager.test.ts b/src/server/tools/toolset/manager.test.ts index 5d525ff..3e50f95 100644 --- a/src/server/tools/toolset/manager.test.ts +++ b/src/server/tools/toolset/manager.test.ts @@ -9,9 +9,13 @@ import os from "os"; import { ToolsetManager } from "./manager.js"; import { ToolsetConfig } from "./types.js"; import { DiscoveredTool, IToolDiscoveryEngine } from "../discovery/types.js"; +import { EventEmitter } from "events"; // Mock discovery engine -class MockDiscoveryEngine implements IToolDiscoveryEngine { +class MockDiscoveryEngine + extends EventEmitter + implements IToolDiscoveryEngine +{ private tools: DiscoveredTool[] = []; setTools(tools: DiscoveredTool[]) { @@ -126,7 +130,7 @@ describe("ToolsetManager", () => { discoveredAt: new Date(), lastUpdated: new Date(), serverStatus: "connected", - toolHash: "hash1", + toolHash: "hash1234567890", }, { name: "ps", @@ -140,7 +144,7 @@ describe("ToolsetManager", () => { discoveredAt: new Date(), lastUpdated: new Date(), serverStatus: "connected", - toolHash: "hash2", + toolHash: "hash0987654321", }, ]; @@ -380,4 +384,50 @@ describe("ToolsetManager", () => { expect(manager.getConfigPath()).toBe(newPath); }); }); + + describe("alias support", () => { + beforeEach(() => { + manager.setDiscoveryEngine(mockDiscovery); + }); + + it("should expose alias names while preserving canonical routing", async () => { + const config: ToolsetConfig = { + name: "aliased", + tools: [ + { namespacedName: "git.status", alias: "git_status" }, + { namespacedName: "docker.ps" }, + ], + }; + + manager.setCurrentToolset(config); + + const tools = manager.getMcpTools(); + expect(tools).toHaveLength(2); + const gitTool = tools.find((tool) => tool.name === "git_status"); + expect(gitTool).toBeDefined(); + expect(gitTool?.description).toContain( + "Alias: git_status (maps to git.status)" + ); + + expect(manager.getOriginalToolName("git_status")).toBe("git.status"); + expect(manager.getOriginalToolName("docker_ps")).toBe("docker.ps"); + + const info = await manager.generateToolsetInfo(config); + const aliasedEntry = info.tools.find( + (tool) => tool.namespacedName === "git.status" + ); + expect(aliasedEntry?.alias).toBe("git_status"); + }); + + it("should resolve aliases defined with refId only", () => { + const config: ToolsetConfig = { + name: "aliased-by-ref", + tools: [{ refId: "hash1234567890", alias: "status_ref" }], + }; + + manager.setCurrentToolset(config); + + expect(manager.getOriginalToolName("status_ref")).toBe("git.status"); + }); + }); }); diff --git a/src/server/tools/toolset/manager.ts b/src/server/tools/toolset/manager.ts index c68b035..8e46ff1 100644 --- a/src/server/tools/toolset/manager.ts +++ b/src/server/tools/toolset/manager.ts @@ -67,11 +67,59 @@ export class ToolsetManager private currentToolset?: ToolsetConfig; private configPath?: string; private discoveryEngine?: IToolDiscoveryEngine; + private aliasRegistry: Map< + string, + { + namespacedName?: string; + refId?: string; + } + > = new Map(); + private aliasByNamespacedName: Map = new Map(); + private aliasByRefId: Map = new Map(); constructor() { super(); } + private rebuildAliasRegistry(): void { + this.aliasRegistry.clear(); + this.aliasByNamespacedName.clear(); + this.aliasByRefId.clear(); + + if (!this.currentToolset) { + return; + } + + for (const toolRef of this.currentToolset.tools) { + const alias = toolRef.alias?.trim(); + if (!alias) { + continue; + } + + this.aliasRegistry.set(alias, { + namespacedName: toolRef.namespacedName, + refId: toolRef.refId, + }); + + if (toolRef.namespacedName) { + this.aliasByNamespacedName.set(toolRef.namespacedName, alias); + } + + if (toolRef.refId) { + this.aliasByRefId.set(toolRef.refId, alias); + } + } + } + + private getAliasForDiscoveredTool( + tool: DiscoveredTool + ): string | undefined { + return ( + this.aliasByNamespacedName.get(tool.namespacedName) || + this.aliasByRefId.get(tool.toolHash) + ); + } + /** * Load toolset configuration from file */ @@ -85,6 +133,7 @@ export class ToolsetManager if (result.config && result.validation.valid) { this.currentToolset = result.config; this.configPath = filePath; + this.rebuildAliasRegistry(); } return { @@ -137,6 +186,7 @@ export class ToolsetManager createdAt: new Date(), tools: [], // Intentionally empty - no default tools }; + this.rebuildAliasRegistry(); return this.currentToolset; } @@ -148,6 +198,7 @@ export class ToolsetManager if (validation.valid) { const previousConfig = this.currentToolset; this.currentToolset = toolsetConfig; + this.rebuildAliasRegistry(); // Emit toolset change event const event: ToolsetChangeEvent = { @@ -207,6 +258,7 @@ export class ToolsetManager clearCurrentToolset(): void { this.currentToolset = undefined; this.configPath = undefined; + this.rebuildAliasRegistry(); } /** @@ -216,12 +268,15 @@ export class ToolsetManager this.discoveryEngine = discoveryEngine; // Listen for discovered tools changes and validate active toolset - (discoveryEngine as any).on( - "toolsChanged", - (event: DiscoveredToolsChangedEvent) => { - this.handleDiscoveredToolsChanged(event); - } - ); + const maybeEmitter = discoveryEngine as unknown as EventEmitter; + if (typeof maybeEmitter.on === "function") { + maybeEmitter.on( + "toolsChanged", + (event: DiscoveredToolsChangedEvent) => { + this.handleDiscoveredToolsChanged(event); + } + ); + } } /** Hydrates the tool with any notes loaded from the toolset configuration. */ @@ -270,12 +325,20 @@ export class ToolsetManager /** Formats a discovered tool into an MCP tool. */ _getToolFromDiscoveredTool(dt: DiscoveredTool): Tool { - let t = dt.tool; - - t.name = this.flattenToolName(dt.namespacedName); - t.description = dt.tool.description || `Tool from ${dt.serverName} server`; + const alias = this.getAliasForDiscoveredTool(dt); + const flattenedName = this.flattenToolName(dt.namespacedName); + const baseDescription = + dt.tool.description || `Tool from ${dt.serverName} server`; + + const tool: Tool = { + ...dt.tool, + name: alias || flattenedName, + description: alias + ? `${baseDescription}\n\nAlias: ${alias} (maps to ${dt.namespacedName})` + : baseDescription, + }; - return t; + return tool; } /** @@ -363,6 +426,23 @@ export class ToolsetManager const activeTools = this.getActiveDiscoveredTools(); + const aliasRecord = this.aliasRegistry.get(flattenedName); + if (aliasRecord) { + if (aliasRecord.namespacedName) { + return aliasRecord.namespacedName; + } + + if (aliasRecord.refId) { + const matchedTool = activeTools.find( + (tool) => tool.toolHash === aliasRecord.refId + ); + + if (matchedTool) { + return matchedTool.namespacedName; + } + } + } + for (const tool of activeTools) { if (this.flattenToolName(tool.namespacedName) === flattenedName) { return tool.namespacedName; @@ -440,18 +520,32 @@ export class ToolsetManager }; } - if (!tools || tools.length === 0) { - return { - meta: { - success: false, - error: "Toolset must include at least one tool", - }, + if (!tools || tools.length === 0) { + return { + meta: { + success: false, + error: "Toolset must include at least one tool", + }, + }; + } + + const sanitizedTools = tools.map((toolRef) => { + const sanitized: DynamicToolReference = { + namespacedName: toolRef.namespacedName, + refId: toolRef.refId, }; - } + + if (typeof toolRef.alias === "string") { + const trimmed = toolRef.alias.trim(); + sanitized.alias = trimmed.length > 0 ? trimmed : undefined; + } + + return sanitized; + }); // Validate tool references if discovery engine is available if (this.discoveryEngine) { - const validationResult = this.validateToolReferences(tools); + const validationResult = this.validateToolReferences(sanitizedTools); if (!validationResult.valid) { return { meta: { @@ -483,7 +577,7 @@ export class ToolsetManager description: options.description, version: "1.0.0", createdAt: new Date(), - tools, + tools: sanitizedTools, }; // Validate configuration @@ -660,6 +754,7 @@ export class ToolsetManager serverName: string; refId: string; context?: ContextInfo; + alias?: string; _tokens?: number; // Store for server-level calculation }> > = {}; @@ -671,9 +766,15 @@ export class ToolsetManager } const toolTokens = tokenCounter.calculateToolTokens(tool); + const alias = this.getAliasForDiscoveredTool(tool); + const baseDescription = tool.tool.description; + const descriptionWithAlias = alias + ? `${baseDescription || `Tool from ${tool.serverName} server`}\n\nAlias: ${alias} (maps to ${tool.namespacedName})` + : baseDescription; + serverToolsMap[tool.serverName].push({ name: tool.name, - description: tool.tool.description, + description: descriptionWithAlias, namespacedName: tool.namespacedName, serverName: tool.serverName, refId: tool.toolHash, @@ -681,6 +782,7 @@ export class ToolsetManager toolTokens, totalPossibleTokens ), + alias, _tokens: toolTokens, // Store for server total }); } @@ -820,6 +922,7 @@ export class ToolsetManager const previousConfig = this.currentToolset; this.currentToolset = undefined; this.configPath = undefined; + this.rebuildAliasRegistry(); // Clear the last equipped toolset from preferences try { @@ -891,9 +994,16 @@ export class ToolsetManager } // Convert discovered tool to ToolInfoResponse with context - exposedTools[tool.serverName].push( - tokenCounter.convertToToolInfoResponse(tool, totalTokens) + const toolInfo = tokenCounter.convertToToolInfoResponse( + tool, + totalTokens ); + const alias = this.getAliasForDiscoveredTool(tool); + if (alias) { + toolInfo.alias = alias; + } + + exposedTools[tool.serverName].push(toolInfo); } // Create response with context information at top level @@ -965,6 +1075,7 @@ export class ToolsetManager refId: string; server: string; active: boolean; + alias?: string; }> = []; if (this.discoveryEngine) { @@ -996,6 +1107,7 @@ export class ToolsetManager refId: resolution.tool.toolHash, server: serverName, active: true, // Tool is available + alias: toolRef.alias, }); } else { // Tool is not available, but we can still include it with the info we have @@ -1004,6 +1116,7 @@ export class ToolsetManager refId: toolRef.refId || "unknown", server: "unknown", active: false, // Tool is not available + alias: toolRef.alias, }); } } @@ -1015,6 +1128,7 @@ export class ToolsetManager refId: toolRef.refId || "unknown", server: "unknown", active: false, // Cannot determine availability without discovery engine + alias: toolRef.alias, })) ); } diff --git a/src/server/tools/toolset/types.ts b/src/server/tools/toolset/types.ts index c8dc626..abb3b68 100644 --- a/src/server/tools/toolset/types.ts +++ b/src/server/tools/toolset/types.ts @@ -13,6 +13,8 @@ export type DynamicToolReference = { namespacedName?: string; /** Tool reference by unique hash identifier (e.g., 'abc123def456...') */ refId?: string; + /** Optional alias that can be used when exposing the tool to clients */ + alias?: string; }; /** diff --git a/src/server/tools/toolset/validator.test.ts b/src/server/tools/toolset/validator.test.ts index 77a4d62..c42f0bb 100644 --- a/src/server/tools/toolset/validator.test.ts +++ b/src/server/tools/toolset/validator.test.ts @@ -201,5 +201,66 @@ describe("ToolsetValidator", () => { "Consider adding refId values to tool references for better validation and security" ); }); + + it("should accept valid aliases", () => { + const config: ToolsetConfig = { + name: "aliased-toolset", + tools: [ + { namespacedName: "git.status", alias: "git_status" }, + { namespacedName: "docker.ps", alias: "docker_ps" }, + ], + }; + + const result = validateToolsetConfig(config); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("should reject duplicate aliases", () => { + const config: ToolsetConfig = { + name: "duplicate-alias", + tools: [ + { namespacedName: "git.status", alias: "status_tool" }, + { namespacedName: "docker.ps", alias: "status_tool" }, + ], + }; + + const result = validateToolsetConfig(config); + expect(result.valid).toBe(false); + expect(result.errors).toContain( + 'Tool reference at index 1: alias "status_tool" is already used by another tool' + ); + }); + + it("should reject aliases that conflict with canonical names", () => { + const config: ToolsetConfig = { + name: "conflicting-alias", + tools: [ + { namespacedName: "git.status" }, + { namespacedName: "docker.ps", alias: "git_status" }, + ], + }; + + const result = validateToolsetConfig(config); + expect(result.valid).toBe(false); + expect(result.errors).toContain( + 'Tool reference at index 1: alias "git_status" conflicts with the canonical name of another tool' + ); + }); + + it("should reject aliases with invalid format", () => { + const config: ToolsetConfig = { + name: "invalid-alias", + tools: [ + { namespacedName: "git.status", alias: "GitStatus" }, + ], + }; + + const result = validateToolsetConfig(config); + expect(result.valid).toBe(false); + expect(result.errors).toContain( + "Tool reference at index 0: alias must contain only lowercase letters, numbers, and underscores" + ); + }); }); }); diff --git a/src/server/tools/toolset/validator.ts b/src/server/tools/toolset/validator.ts index d92d820..11442f0 100644 --- a/src/server/tools/toolset/validator.ts +++ b/src/server/tools/toolset/validator.ts @@ -8,6 +8,12 @@ import { DynamicToolReference, } from "./types.js"; +const ALIAS_PATTERN = /^[a-z0-9_]+$/; + +function flattenToolName(namespacedName: string): string { + return namespacedName.replace(/\./g, "_"); +} + /** * Validate a toolset configuration */ @@ -36,9 +42,18 @@ export function validateToolsetConfig(config: ToolsetConfig): ValidationResult { errors.push("Configuration must specify at least one tool"); } else { // Validate each tool reference + const canonicalNameByIndex = new Map(); + config.tools.forEach((toolRef, index) => { const toolErrors = validateToolReference(toolRef, index); errors.push(...toolErrors); + + if (toolRef.namespacedName) { + canonicalNameByIndex.set( + index, + flattenToolName(toolRef.namespacedName) + ); + } }); // Check for duplicate tool references @@ -63,6 +78,75 @@ export function validateToolsetConfig(config: ToolsetConfig): ValidationResult { `Duplicate tool references found: ${Array.from(duplicates).join(", ")}` ); } + + // Build lookup for canonical flattened names + const canonicalNameToIndex = new Map(); + canonicalNameByIndex.forEach((canonical, index) => { + if (!canonicalNameToIndex.has(canonical)) { + canonicalNameToIndex.set(canonical, index); + } + }); + + // Validate aliases (format, uniqueness, collisions) + const aliasToIndex = new Map(); + + config.tools.forEach((ref, index) => { + if (ref.alias === undefined) { + return; + } + + if (typeof ref.alias !== "string") { + errors.push(`Tool reference at index ${index}: alias must be a string`); + return; + } + + const trimmedAlias = ref.alias.trim(); + + if (trimmedAlias.length === 0) { + errors.push(`Tool reference at index ${index}: alias cannot be empty`); + return; + } + + if (trimmedAlias.length < 2 || trimmedAlias.length > 50) { + errors.push( + `Tool reference at index ${index}: alias must be between 2 and 50 characters` + ); + } + + if (!ALIAS_PATTERN.test(trimmedAlias)) { + errors.push( + `Tool reference at index ${index}: alias must contain only lowercase letters, numbers, and underscores` + ); + } + + const existingAliasIndex = aliasToIndex.get(trimmedAlias); + if (existingAliasIndex !== undefined && existingAliasIndex !== index) { + errors.push( + `Tool reference at index ${index}: alias "${trimmedAlias}" is already used by another tool` + ); + } else { + aliasToIndex.set(trimmedAlias, index); + } + + const canonicalOwnerIndex = canonicalNameToIndex.get(trimmedAlias); + + if (canonicalOwnerIndex !== undefined && canonicalOwnerIndex !== index) { + errors.push( + `Tool reference at index ${index}: alias "${trimmedAlias}" conflicts with the canonical name of another tool` + ); + } + + if (ref.namespacedName && trimmedAlias === ref.namespacedName) { + warnings.push( + `Tool reference at index ${index}: alias matches the namespaced name; consider omitting the alias` + ); + } + + // Update alias to trimmed value for downstream consumers + if (ref.alias !== trimmedAlias) { + ref.alias = trimmedAlias; + } + }); } // Optional fields validation diff --git a/src/test-utils/test-config-tools.sh b/src/test-utils/test-config-tools.sh index 7b532cb..8c6a014 100755 --- a/src/test-utils/test-config-tools.sh +++ b/src/test-utils/test-config-tools.sh @@ -15,29 +15,126 @@ NC='\033[0m' # No Color # Configuration PORT=3456 SERVER_URL="http://localhost:$PORT/mcp" -TEST_MCP_CONFIG="mcp.test.json" +TEST_MCP_CONFIG="" TEST_PERSONA="test-persona" SERVER_PID="" LOG_DIR="src/test-utils/logs" LOG_FILE="" SESSION_ID="" +TEMP_STANDARD_CONFIG="" +TEMP_PERSONA_DIR="" # Create log directory if it doesn't exist mkdir -p "$LOG_DIR" # Cleanup function -cleanup() { - echo -e "${YELLOW}Cleaning up...${NC}" +stop_server() { if [ ! -z "$SERVER_PID" ]; then echo "Killing server with PID $SERVER_PID" kill $SERVER_PID 2>/dev/null || true wait $SERVER_PID 2>/dev/null || true + SERVER_PID="" + fi +} + +cleanup() { + echo -e "${YELLOW}Cleaning up...${NC}" + stop_server + if [ -n "$TEMP_STANDARD_CONFIG" ] && [ -f "$TEMP_STANDARD_CONFIG" ]; then + rm -f "$TEMP_STANDARD_CONFIG" + fi + if [ -n "$TEMP_PERSONA_DIR" ] && [ -d "$TEMP_PERSONA_DIR" ]; then + rm -rf "$TEMP_PERSONA_DIR" fi } # Set trap for cleanup trap cleanup EXIT +# Create stub MCP configuration for standard mode tests +create_stub_standard_config() { + TEMP_STANDARD_CONFIG=$(mktemp) + cat >"$TEMP_STANDARD_CONFIG" <<'JSON' +{ + "mcpServers": { + "sequential-thinking": { + "type": "stdio", + "command": "node", + "args": ["./test/stub-servers/mcp-stub.mjs"], + "env": { + "STUB_SERVER_NAME": "sequential-thinking" + } + }, + "everything": { + "type": "stdio", + "command": "node", + "args": ["./test/stub-servers/mcp-stub.mjs"], + "env": { + "STUB_SERVER_NAME": "everything" + } + } + } +} +JSON + TEST_MCP_CONFIG="$TEMP_STANDARD_CONFIG" +} + +# Create temporary persona directory with stub MCP servers +create_stub_persona_dir() { + TEMP_PERSONA_DIR=$(mktemp -d) + mkdir -p "$TEMP_PERSONA_DIR/$TEST_PERSONA" + + cp personas/test-persona/persona.yaml "$TEMP_PERSONA_DIR/$TEST_PERSONA/persona.yaml" + + cat >"$TEMP_PERSONA_DIR/$TEST_PERSONA/mcp.json" <<'JSON' +{ + "mcpServers": { + "everything": { + "type": "stdio", + "command": "node", + "args": ["./test/stub-servers/mcp-stub.mjs"], + "env": { + "STUB_SERVER_NAME": "everything" + } + }, + "context7": { + "type": "stdio", + "command": "node", + "args": ["./test/stub-servers/mcp-stub.mjs"], + "env": { + "STUB_SERVER_NAME": "context7" + } + }, + "mcping": { + "type": "stdio", + "command": "node", + "args": ["./test/stub-servers/mcp-stub.mjs"], + "env": { + "STUB_SERVER_NAME": "mcping" + } + }, + "filesystem": { + "type": "stdio", + "command": "node", + "args": ["./test/stub-servers/mcp-stub.mjs"], + "env": { + "STUB_SERVER_NAME": "filesystem" + } + } + } +} +JSON + +} + +initialize_stub_environment() { + if [ -z "$TEST_MCP_CONFIG" ]; then + create_stub_standard_config + fi + + create_stub_persona_dir +} + # Function to initialize MCP session init_session() { local request='{ @@ -85,7 +182,7 @@ start_server() { if [ "$mode" = "persona" ]; then echo -e "${BLUE}Starting server in PERSONA mode with --persona $TEST_PERSONA${NC}" LOG_FILE="$LOG_DIR/test-persona-mode-$timestamp.log" - cmd="dist/bin.js mcp run --persona $TEST_PERSONA --transport http --port $PORT --debug --log-level debug" + cmd="env HYPERTOOL_PERSONA_DIR=$TEMP_PERSONA_DIR dist/bin.js mcp run --persona $TEST_PERSONA --transport http --port $PORT --debug --log-level debug" else echo -e "${BLUE}Starting server in STANDARD mode with --mcp-config $TEST_MCP_CONFIG${NC}" LOG_FILE="$LOG_DIR/test-standard-mode-$timestamp.log" @@ -102,7 +199,7 @@ start_server() { echo "Waiting for server to be ready..." # Wait for server to start - local max_attempts=30 + local max_attempts=120 local attempt=0 while [ $attempt -lt $max_attempts ]; do if curl -s "$SERVER_URL" > /dev/null 2>&1; then @@ -251,6 +348,11 @@ main() { npm run build > /dev/null 2>&1 echo -e "${GREEN}Build complete${NC}" + # Prepare stub configurations so tests don't require external MCP servers + initialize_stub_environment + echo -e "${BLUE}Using stub MCP config: ${TEST_MCP_CONFIG}${NC}" + echo -e "${BLUE}Using stub persona: ${TEST_PERSONA}${NC}" + # Test 1: Standard mode (no persona) echo -e "\n${GREEN}=== TEST SUITE 1: Standard Mode (No Persona) ===${NC}" start_server "standard" @@ -285,6 +387,7 @@ main() { # Test build-toolset (should work) echo -e "\n${YELLOW}TEST: build-toolset in standard mode${NC}" + call_tool "delete-toolset" '{"name": "test-toolset", "confirm": true}' >/dev/null 2>&1 || true build_args='{ "name": "test-toolset", "tools": [{"namespacedName": "sequential-thinking.sequentialthinking"}] @@ -300,7 +403,7 @@ main() { fi # Clean up server - cleanup + stop_server sleep 2 # Test 2: Persona mode @@ -345,7 +448,7 @@ main() { echo -e "${GREEN}✓ PASS - build-toolset is hidden in persona mode (verified above)${NC}" # Clean up server - cleanup + stop_server fi echo -e "\n${GREEN}=== Test Complete ===${NC}" diff --git a/src/test-utils/test-persona-toolset-activation.sh b/src/test-utils/test-persona-toolset-activation.sh index bc986f8..d7305c2 100755 --- a/src/test-utils/test-persona-toolset-activation.sh +++ b/src/test-utils/test-persona-toolset-activation.sh @@ -8,6 +8,8 @@ SERVER_URL="http://localhost:$PORT/mcp" SERVER_PID="" SESSION_ID="" PERSONA_YAML="test/fixtures/personas/test-persona/persona.yaml" +TEMP_PERSONA_DIR="" +TEMP_MCP_CONFIG="" # Colors GREEN='\033[0;32m' @@ -20,10 +22,90 @@ cleanup() { if [ ! -z "$SERVER_PID" ]; then kill $SERVER_PID 2>/dev/null || true fi + if [ -n "$TEMP_PERSONA_DIR" ] && [ -d "$TEMP_PERSONA_DIR" ]; then + rm -rf "$TEMP_PERSONA_DIR" + fi + if [ -n "$TEMP_MCP_CONFIG" ] && [ -f "$TEMP_MCP_CONFIG" ]; then + rm -f "$TEMP_MCP_CONFIG" + fi } trap cleanup EXIT +# Create a temporary persona directory using stub MCP servers +setup_stub_persona() { + TEMP_PERSONA_DIR=$(mktemp -d) + mkdir -p "$TEMP_PERSONA_DIR/test-persona" + + cp personas/test-persona/persona.yaml "$TEMP_PERSONA_DIR/test-persona/persona.yaml" + PERSONA_YAML="$TEMP_PERSONA_DIR/test-persona/persona.yaml" + + cat >"$TEMP_PERSONA_DIR/test-persona/mcp.json" <<'JSON' +{ + "mcpServers": { + "everything": { + "type": "stdio", + "command": "node", + "args": ["./test/stub-servers/mcp-stub.mjs"], + "env": { + "STUB_SERVER_NAME": "everything" + } + }, + "context7": { + "type": "stdio", + "command": "node", + "args": ["./test/stub-servers/mcp-stub.mjs"], + "env": { + "STUB_SERVER_NAME": "context7" + } + }, + "mcping": { + "type": "stdio", + "command": "node", + "args": ["./test/stub-servers/mcp-stub.mjs"], + "env": { + "STUB_SERVER_NAME": "mcping" + } + }, + "filesystem": { + "type": "stdio", + "command": "node", + "args": ["./test/stub-servers/mcp-stub.mjs"], + "env": { + "STUB_SERVER_NAME": "filesystem" + } + } + } +} +JSON +} + +create_stub_mcp_config() { + TEMP_MCP_CONFIG=$(mktemp) + cat >"$TEMP_MCP_CONFIG" <<'JSON' +{ + "mcpServers": { + "sequential-thinking": { + "type": "stdio", + "command": "node", + "args": ["./test/stub-servers/mcp-stub.mjs"], + "env": { + "STUB_SERVER_NAME": "sequential-thinking" + } + }, + "everything": { + "type": "stdio", + "command": "node", + "args": ["./test/stub-servers/mcp-stub.mjs"], + "env": { + "STUB_SERVER_NAME": "everything" + } + } + } +} +JSON +} + # Function to parse tool IDs from persona.yaml for a given toolset get_toolset_tools() { local toolset_name="$1" @@ -88,7 +170,9 @@ fi # Start server in persona mode with debug and default toolset echo -e "${YELLOW}1. Starting server in persona mode (test-persona) with default toolset (utility-tools)...${NC}" -dist/bin.js mcp run --persona test-persona --equip-toolset utility-tools --transport http --port $PORT --debug --log-level debug > persona-toolset-test.log 2>&1 & +setup_stub_persona +create_stub_mcp_config +env HYPERTOOL_PERSONA_DIR=$TEMP_PERSONA_DIR dist/bin.js mcp run --persona test-persona --equip-toolset utility-tools --mcp-config "$TEMP_MCP_CONFIG" --transport http --port $PORT --debug --log-level debug > persona-toolset-test.log 2>&1 & SERVER_PID=$! # Wait for server diff --git a/src/test-utils/test-tool-alias-routing.sh b/src/test-utils/test-tool-alias-routing.sh new file mode 100755 index 0000000..f2055cf --- /dev/null +++ b/src/test-utils/test-tool-alias-routing.sh @@ -0,0 +1,200 @@ +#!/bin/bash + +set -euo pipefail + +PORT=3460 +SERVER_URL="http://localhost:$PORT/mcp" +LOG_DIR="src/test-utils/logs" +LOG_FILE="" +SESSION_ID="" +SERVER_PID="" +TEMP_CONFIG="" + +mkdir -p "$LOG_DIR" + +cleanup() { + if [ -n "$SERVER_PID" ]; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + if [ -n "$TEMP_CONFIG" ] && [ -f "$TEMP_CONFIG" ]; then + rm -f "$TEMP_CONFIG" + fi +} + +trap cleanup EXIT + +create_temp_config() { + TEMP_CONFIG=$(mktemp) + cat >"$TEMP_CONFIG" <<'JSON' +{ + "mcpServers": { + "everything": { + "type": "stdio", + "command": "node", + "args": ["./test/stub-servers/mcp-stub.mjs"], + "env": { + "STUB_SERVER_NAME": "everything" + } + }, + "sequential-thinking": { + "type": "stdio", + "command": "node", + "args": ["./test/stub-servers/mcp-stub.mjs"], + "env": { + "STUB_SERVER_NAME": "sequential-thinking" + } + } + } +} +JSON +} + +wait_for_server() { + local attempts=0 + local max_attempts=30 + while [ $attempts -lt $max_attempts ]; do + if curl -s "$SERVER_URL" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + attempts=$((attempts + 1)) + done + echo "Server failed to start" >&2 + return 1 +} + +start_server() { + LOG_FILE="$LOG_DIR/test-tool-alias-routing.log" + dist/bin.js mcp run --mcp-config "$TEMP_CONFIG" --transport http --port "$PORT" --debug --log-level debug >"$LOG_FILE" 2>&1 & + SERVER_PID=$! + wait_for_server +} + +init_session() { + local response_file="/tmp/mcp-init-$$" + curl -s -i -X POST "$SERVER_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: text/event-stream, application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": { + "name": "tool-alias-test", + "version": "1.0.0" + } + } + }' >"$response_file" + + SESSION_ID=$(grep -i "^Mcp-Session-Id:" "$response_file" | cut -d' ' -f2 | tr -d '\r\n' || true) + rm -f "$response_file" + + if [ -z "$SESSION_ID" ]; then + echo "Failed to obtain MCP session id" >&2 + exit 1 + fi +} + +build_request() { + local tool_name="$1" + local args_json="${2:-}" + + if [ -n "$args_json" ]; then + jq -n --arg name "$tool_name" --argjson args "$args_json" '{ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: $name, + arguments: $args + } + }' + else + jq -n --arg name "$tool_name" '{ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: $name + } + }' + fi +} + +call_tool() { + local tool_name="$1" + local args_json="${2:-}" + local request=$(build_request "$tool_name" "$args_json") + + local response=$(curl -s -X POST "$SERVER_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: text/event-stream, application/json" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + -d "$request") + + if echo "$response" | grep -q "^event:"; then + echo "$response" | grep "^data:" | sed 's/^data: //' | jq -r '.result' + else + echo "$response" | jq -r '.result' + fi +} + +list_tools() { + local request='{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list" + }' + + local response=$(curl -s -X POST "$SERVER_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: text/event-stream, application/json" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + -d "$request") + + if echo "$response" | grep -q "^event:"; then + echo "$response" | grep "^data:" | sed 's/^data: //' | jq -r '.result.tools[].name' + else + echo "$response" | jq -r '.result.tools[].name' + fi +} + +main() { + npm run build >/dev/null 2>&1 + create_temp_config + start_server + init_session + + local build_payload='{ + "name": "alias-suite", + "tools": [ + {"namespacedName": "everything.echo", "alias": "echo_alias"}, + {"namespacedName": "everything.add"} + ], + "autoEquip": true + }' + + local build_result=$(call_tool "build-toolset" "$build_payload") + echo "$build_result" | jq '.meta.success' | grep -q true + + call_tool "exit-configuration-mode" "{}" >/dev/null 2>&1 || true + + local tools=$(list_tools) + echo "$tools" | grep -q '^echo_alias$' + echo "$tools" | grep -q '^everything_add$' + + local alias_result=$(call_tool "echo_alias" '{"text": "alias success"}') + echo "$alias_result" | jq -r '.content[0].text' | grep -q 'alias success' + + local canonical_result=$(call_tool "everything_add" '{"a": 2, "b": 3}') + echo "$canonical_result" | jq -r '.content[0].text' | grep -q '^5$' + + local available=$(call_tool "list-available-tools" "{}") + echo "$available" | jq '.toolsByServer[] | select(.serverName=="everything") | .tools[] | select(.namespacedName=="everything.echo").alias' | grep -q '"echo_alias"' +} + +main diff --git a/test/stub-servers/mcp-stub.mjs b/test/stub-servers/mcp-stub.mjs new file mode 100644 index 0000000..fc03c44 --- /dev/null +++ b/test/stub-servers/mcp-stub.mjs @@ -0,0 +1,224 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; + +const serverName = process.env.STUB_SERVER_NAME || "stub"; + +const server = new McpServer( + { + name: serverName, + version: "0.0.1", + description: "Stub MCP server for integration tests", + }, + { + capabilities: { + tools: { listChanged: true }, + }, + } +); + +const textResult = (text) => ({ + content: [ + { + type: "text", + text, + }, + ], +}); + +switch (serverName) { + case "sequential-thinking": { + server.registerTool( + "sequentialthinking", + { + description: "Stub sequential thinking tool", + inputSchema: { + prompt: z.string().optional().describe("Prompt to analyze"), + }, + }, + async ({ prompt } = {}) => + textResult( + prompt + ? `Stub sequentialthinking result for: ${prompt}` + : "Stub sequentialthinking result" + ) + ); + break; + } + case "everything": { + server.registerTool( + "echo", + { + description: "Echo back provided text", + inputSchema: { + text: z.string().describe("Text to echo"), + }, + }, + async ({ text }) => textResult(text) + ); + server.registerTool( + "add", + { + description: "Add two numbers", + inputSchema: { + a: z.number().describe("First addend"), + b: z.number().describe("Second addend"), + }, + }, + async ({ a, b }) => textResult(`${a + b}`) + ); + server.registerTool( + "get_request_info", + { + description: "Return stub request metadata", + inputSchema: { + includeHeaders: z + .boolean() + .optional() + .describe("Whether to include header summary"), + }, + }, + async ({ includeHeaders } = {}) => + textResult( + includeHeaders + ? "Stub request info with headers" + : "Stub request info" + ) + ); + break; + } + case "filesystem": { + server.registerTool( + "read_file", + { + description: "Return placeholder file contents", + inputSchema: { + path: z.string().describe("File path to read"), + }, + }, + async ({ path }) => textResult(`Contents of ${path}`) + ); + server.registerTool( + "write_file", + { + description: "Stub write file", + inputSchema: { + path: z.string().describe("File path to write"), + content: z.string().describe("Content to write"), + }, + }, + async ({ path }) => textResult(`Wrote file ${path}`) + ); + server.registerTool( + "list_directory", + { + description: "List directory contents", + inputSchema: { + path: z.string().optional().describe("Directory to list"), + }, + }, + async ({ path }) => + textResult(`Directory listing for ${path || "."}: stub-file.txt`) + ); + server.registerTool( + "create_directory", + { + description: "Stub create directory", + inputSchema: { + path: z.string().describe("Directory to create"), + }, + }, + async ({ path }) => textResult(`Created directory ${path}`) + ); + server.registerTool( + "move_file", + { + description: "Stub move file", + inputSchema: { + source: z.string().describe("Source path"), + destination: z.string().describe("Destination path"), + }, + }, + async ({ source, destination }) => + textResult(`Moved ${source} to ${destination}`) + ); + server.registerTool( + "search_files", + { + description: "Stub search files", + inputSchema: { + path: z.string().optional().describe("Search root"), + pattern: z.string().optional().describe("Pattern"), + }, + }, + async ({ path, pattern }) => + textResult( + `Search results for ${pattern || "*"} under ${path || "."}` + ) + ); + break; + } + case "mcping": { + server.registerTool( + "send-notification", + { + description: "Stub notification sender", + inputSchema: { + message: z.string().optional().describe("Message to send"), + }, + }, + async ({ message } = {}) => + textResult( + message + ? `Notification sent: ${message}` + : "Notification sent" + ) + ); + break; + } + case "context7": { + server.registerTool( + "resolve-library-id", + { + description: "Stub resolve library id", + inputSchema: { + slug: z.string().describe("Library slug"), + }, + }, + async ({ slug }) => textResult(`Resolved library for ${slug}`) + ); + server.registerTool( + "get-library-docs", + { + description: "Stub get library docs", + inputSchema: { + libraryId: z.string().describe("Library identifier"), + query: z.string().optional().describe("Search query"), + }, + }, + async ({ libraryId, query }) => + textResult( + `Docs for ${libraryId}${query ? ` with query ${query}` : ""}` + ) + ); + break; + } + default: { + console.error(`Unknown STUB_SERVER_NAME: ${serverName}`); + process.exit(1); + } +} + +const transport = new StdioServerTransport(); + +server + .connect(transport) + .catch((error) => { + console.error("Failed to start stub MCP server:", error); + process.exit(1); + }); + +process.on("SIGINT", async () => { + await server.close(); + process.exit(0); +});