From 0ac987f99545fc33a2e534ec6c1f8db558288897 Mon Sep 17 00:00:00 2001 From: Uchenna <110002944+I-am-Uchenna@users.noreply.github.com> Date: Fri, 19 Jun 2026 00:24:40 +0200 Subject: [PATCH] feat(mcp): add ponytail-mcp, an MCP server for the ruleset (#91) * feat(mcp): add ponytail-mcp server (prompt + tool) * test(mcp): cover mode resolution and instruction text * Report resolved MCP mode --- ponytail-mcp/README.md | 46 ++++++++++++++++++++++++ ponytail-mcp/index.js | 48 ++++++++++++++++++++++++++ ponytail-mcp/instructions.js | 26 ++++++++++++++ ponytail-mcp/package.json | 13 +++++++ ponytail-mcp/test/instructions.test.js | 22 ++++++++++++ 5 files changed, 155 insertions(+) create mode 100644 ponytail-mcp/README.md create mode 100644 ponytail-mcp/index.js create mode 100644 ponytail-mcp/instructions.js create mode 100644 ponytail-mcp/package.json create mode 100644 ponytail-mcp/test/instructions.test.js diff --git a/ponytail-mcp/README.md b/ponytail-mcp/README.md new file mode 100644 index 0000000..3ec48a1 --- /dev/null +++ b/ponytail-mcp/README.md @@ -0,0 +1,46 @@ +# ponytail-mcp + +An MCP server that serves Ponytail's lazy-senior-dev instructions. It exposes +the same ruleset the Claude hooks and Pi extension use, so every host emits +identical rules. + +It is not a replacement for the always-on adapters. Ponytail normally lives in +the system context every turn. MCP prompts are user-invoked, and there is no +portable MCP primitive for "inject this into every turn" across hosts. So this +server is the clean option for MCP hosts whose only injection point is the +prompt menu, or that pull context through tools. See issue #70. + +## What it exposes + +- Prompt `ponytail` — returns the ruleset as a user message. Optional `mode` + argument: `lite`, `full`, or `ultra`. Omit it to use the configured default. +- Tool `ponytail_instructions` — same text, plus `structuredContent` + (`{ mode, instructions }`), for hosts that pull context via tools or code + execution. Read-only. + +Mode resolution reuses `hooks/ponytail-config.js`, so `PONYTAIL_DEFAULT_MODE` +and `~/.config/ponytail/config.json` work the same as everywhere else. + +## Run it + +```bash +cd ponytail-mcp +npm install +node index.js # speaks MCP over stdio +``` + +Point an MCP host at that command. Example client entry: + +```json +{ "mcpServers": { "ponytail": { "command": "node", "args": ["ponytail-mcp/index.js"] } } } +``` + +## Test + +```bash +npm test +``` + +Covers mode resolution and the instruction text. The MCP wiring in `index.js` +is intentionally thin: it just maps the prompt and tool onto +`buildInstructions`. diff --git a/ponytail-mcp/index.js b/ponytail-mcp/index.js new file mode 100644 index 0000000..4978398 --- /dev/null +++ b/ponytail-mcp/index.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node +// Ponytail MCP server: serves the lazy-senior-dev ruleset over stdio as a +// prompt (user-invoked) and a tool (for hosts that pull context via tools). +// It does NOT replace the always-on adapters; it's the clean option for hosts +// whose only injection point is the prompt menu (see #70). +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; + +import { MODES, buildInstructions, resolveMode } from "./instructions.js"; + +const server = new McpServer({ name: "ponytail", version: "0.1.0" }); + +const modeArg = z + .enum(MODES) + .optional() + .describe("Ponytail intensity: lite, full, or ultra. Omit for the configured default."); + +server.registerPrompt( + "ponytail", + { + title: "Ponytail mode", + description: "Lazy senior dev instructions: YAGNI, stdlib first, the smallest correct change.", + argsSchema: { mode: modeArg }, + }, + ({ mode }) => ({ + messages: [{ role: "user", content: { type: "text", text: buildInstructions(mode) } }], + }), +); + +server.registerTool( + "ponytail_instructions", + { + title: "Ponytail instructions", + description: "Return the Ponytail ruleset for the given intensity (lite, full, or ultra).", + inputSchema: { mode: modeArg }, + outputSchema: { mode: z.string(), instructions: z.string() }, + annotations: { readOnlyHint: true, openWorldHint: false }, + }, + ({ mode }) => { + const resolvedMode = resolveMode(mode); + const instructions = buildInstructions(resolvedMode); + const structuredContent = { mode: resolvedMode, instructions }; + return { content: [{ type: "text", text: instructions }], structuredContent }; + }, +); + +await server.connect(new StdioServerTransport()); diff --git a/ponytail-mcp/instructions.js b/ponytail-mcp/instructions.js new file mode 100644 index 0000000..1cb1c73 --- /dev/null +++ b/ponytail-mcp/instructions.js @@ -0,0 +1,26 @@ +// Pure instruction selection for the Ponytail MCP server. No MCP/SDK imports, +// so this stays unit-testable on its own. Reuses the same builder the Claude +// hooks and Pi extension use, so every host emits identical rules. +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { getPonytailInstructions } = require("../hooks/ponytail-instructions.js"); +const { getDefaultMode, normalizeMode } = require("../hooks/ponytail-config.js"); + +// The three intensities the server offers. "off" has no instructions to serve. +export const MODES = ["lite", "full", "ultra"]; + +// Resolve a requested mode to a runtime intensity. Unknown, empty, or "off" +// falls back to the configured default, then to "full". +// ponytail: keep the surface to these three; "off"/"review" aren't served here. +export function resolveMode(requested) { + const asked = normalizeMode(requested); + if (asked && asked !== "off") return asked; + + const fallback = normalizeMode(getDefaultMode()); + return fallback && fallback !== "off" ? fallback : "full"; +} + +export function buildInstructions(requested) { + return getPonytailInstructions(resolveMode(requested)); +} diff --git a/ponytail-mcp/package.json b/ponytail-mcp/package.json new file mode 100644 index 0000000..ea10f64 --- /dev/null +++ b/ponytail-mcp/package.json @@ -0,0 +1,13 @@ +{ + "name": "ponytail-mcp", + "version": "0.1.0", + "description": "MCP server that serves Ponytail's lazy-senior-dev instructions as a prompt and a tool.", + "type": "module", + "license": "MIT", + "bin": { "ponytail-mcp": "./index.js" }, + "scripts": { "test": "node --test ./test/*.test.js" }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.19.0", + "zod": "^3.23.0" + } +} diff --git a/ponytail-mcp/test/instructions.test.js b/ponytail-mcp/test/instructions.test.js new file mode 100644 index 0000000..5c6125a --- /dev/null +++ b/ponytail-mcp/test/instructions.test.js @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { MODES, resolveMode, buildInstructions } from "../instructions.js"; + +test("resolveMode keeps valid intensities", () => { + for (const mode of MODES) assert.equal(resolveMode(mode), mode); +}); + +test("resolveMode falls back to a runtime intensity for off/unknown/empty", () => { + // PONYTAIL_DEFAULT_MODE could be anything in CI, so just assert the contract: + // never returns "off", "review", or junk — always one of the served modes. + for (const input of ["off", "review", "nonsense", "", undefined, null]) { + assert.ok(MODES.includes(resolveMode(input)), `resolveMode(${input}) must be a served mode`); + } +}); + +test("buildInstructions returns the ruleset tagged with the resolved mode", () => { + const text = buildInstructions("ultra"); + assert.match(text, /PONYTAIL MODE ACTIVE/); + assert.match(text, /ultra/); +});