|
| 1 | +import { describe, expect, it, vi } from "vitest"; |
| 2 | + |
| 3 | +const { registeredTools } = vi.hoisted(() => { |
| 4 | + const registeredTools: Array<{ name: string; description: string }> = []; |
| 5 | + return { registeredTools }; |
| 6 | +}); |
| 7 | + |
| 8 | +vi.mock("@modelcontextprotocol/sdk/server/mcp.js", () => ({ |
| 9 | + McpServer: class { |
| 10 | + registerTool( |
| 11 | + name: string, |
| 12 | + opts: { description: string; inputSchema: unknown }, |
| 13 | + _handler: unknown |
| 14 | + ) { |
| 15 | + registeredTools.push({ name, description: opts.description }); |
| 16 | + } |
| 17 | + }, |
| 18 | +})); |
| 19 | + |
| 20 | +vi.mock("@modelcontextprotocol/sdk/server/stdio.js", () => ({ |
| 21 | + StdioServerTransport: class {}, |
| 22 | +})); |
| 23 | + |
| 24 | +// Triggers all registerTool calls at module scope |
| 25 | +import "./server.js"; |
| 26 | + |
| 27 | +// SEP-986 (Final): tool names SHOULD be 1–64 characters |
| 28 | +const TOOL_NAME_MAX_LENGTH = 64; |
| 29 | +// Allowed: a-zA-Z0-9 _ - . / |
| 30 | +const TOOL_NAME_PATTERN = /^[a-zA-Z0-9_\-./]+$/; |
| 31 | +// No hard spec limit; 1024 is the practical ceiling across major LLM providers |
| 32 | +const TOOL_DESCRIPTION_MAX_LENGTH = 1024; |
| 33 | +// Cursor IDE prefixes tool names with "extension-<server>:" when displaying them. |
| 34 | +// Combined length must stay ≤ 60 to avoid filtering warnings. |
| 35 | +const CURSOR_SERVER_PREFIX = "extension-currents:"; |
| 36 | +const CURSOR_COMBINED_MAX_LENGTH = 60; |
| 37 | + |
| 38 | +describe("MCP tool best practices", () => { |
| 39 | + it("has at least one registered tool", () => { |
| 40 | + expect(registeredTools.length).toBeGreaterThan(0); |
| 41 | + }); |
| 42 | + |
| 43 | + it("tool names are unique", () => { |
| 44 | + const names = registeredTools.map((t) => t.name); |
| 45 | + const dupes = names.filter((n, i) => names.indexOf(n) !== i); |
| 46 | + expect(dupes, `duplicate tool names: ${dupes.join(", ")}`).toHaveLength(0); |
| 47 | + }); |
| 48 | + |
| 49 | + describe.each(registeredTools)("$name", ({ name, description }) => { |
| 50 | + // ── name constraints (SEP-986) ────────────────────────────── |
| 51 | + it(`name length ≤ ${TOOL_NAME_MAX_LENGTH}`, () => { |
| 52 | + expect( |
| 53 | + name.length, |
| 54 | + `"${name}" is ${name.length} chars` |
| 55 | + ).toBeLessThanOrEqual(TOOL_NAME_MAX_LENGTH); |
| 56 | + }); |
| 57 | + |
| 58 | + it("name contains only allowed characters", () => { |
| 59 | + expect(name).toMatch(TOOL_NAME_PATTERN); |
| 60 | + }); |
| 61 | + |
| 62 | + it("name is not empty", () => { |
| 63 | + expect(name.length).toBeGreaterThan(0); |
| 64 | + }); |
| 65 | + |
| 66 | + it(`combined Cursor name length ≤ ${CURSOR_COMBINED_MAX_LENGTH}`, () => { |
| 67 | + const combined = `${CURSOR_SERVER_PREFIX}${name}`; |
| 68 | + expect( |
| 69 | + combined.length, |
| 70 | + `"${combined}" is ${combined.length} chars` |
| 71 | + ).toBeLessThanOrEqual(CURSOR_COMBINED_MAX_LENGTH); |
| 72 | + }); |
| 73 | + |
| 74 | + // ── description constraints ───────────────────────────────── |
| 75 | + it("description is not empty", () => { |
| 76 | + expect(description.length).toBeGreaterThan(0); |
| 77 | + }); |
| 78 | + |
| 79 | + it(`description length ≤ ${TOOL_DESCRIPTION_MAX_LENGTH}`, () => { |
| 80 | + expect( |
| 81 | + description.length, |
| 82 | + `description is ${description.length} chars` |
| 83 | + ).toBeLessThanOrEqual(TOOL_DESCRIPTION_MAX_LENGTH); |
| 84 | + }); |
| 85 | + |
| 86 | + it("description has no leading/trailing whitespace", () => { |
| 87 | + expect(description).toBe(description.trim()); |
| 88 | + }); |
| 89 | + |
| 90 | + it("description starts with a capital letter", () => { |
| 91 | + expect(description).toMatch(/^[A-Z]/); |
| 92 | + }); |
| 93 | + |
| 94 | + it("description ends with a period", () => { |
| 95 | + expect(description.at(-1)).toBe("."); |
| 96 | + }); |
| 97 | + }); |
| 98 | +}); |
0 commit comments