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
This commit is contained in:
@@ -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`.
|
||||||
@@ -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());
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user