Skip to content

Commit 0ac987f

Browse files
authored
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
1 parent 15749f7 commit 0ac987f

5 files changed

Lines changed: 155 additions & 0 deletions

File tree

‎ponytail-mcp/README.md‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# ponytail-mcp
2+
3+
An MCP server that serves Ponytail's lazy-senior-dev instructions. It exposes
4+
the same ruleset the Claude hooks and Pi extension use, so every host emits
5+
identical rules.
6+
7+
It is not a replacement for the always-on adapters. Ponytail normally lives in
8+
the system context every turn. MCP prompts are user-invoked, and there is no
9+
portable MCP primitive for "inject this into every turn" across hosts. So this
10+
server is the clean option for MCP hosts whose only injection point is the
11+
prompt menu, or that pull context through tools. See issue #70.
12+
13+
## What it exposes
14+
15+
- Prompt `ponytail` — returns the ruleset as a user message. Optional `mode`
16+
argument: `lite`, `full`, or `ultra`. Omit it to use the configured default.
17+
- Tool `ponytail_instructions` — same text, plus `structuredContent`
18+
(`{ mode, instructions }`), for hosts that pull context via tools or code
19+
execution. Read-only.
20+
21+
Mode resolution reuses `hooks/ponytail-config.js`, so `PONYTAIL_DEFAULT_MODE`
22+
and `~/.config/ponytail/config.json` work the same as everywhere else.
23+
24+
## Run it
25+
26+
```bash
27+
cd ponytail-mcp
28+
npm install
29+
node index.js # speaks MCP over stdio
30+
```
31+
32+
Point an MCP host at that command. Example client entry:
33+
34+
```json
35+
{ "mcpServers": { "ponytail": { "command": "node", "args": ["ponytail-mcp/index.js"] } } }
36+
```
37+
38+
## Test
39+
40+
```bash
41+
npm test
42+
```
43+
44+
Covers mode resolution and the instruction text. The MCP wiring in `index.js`
45+
is intentionally thin: it just maps the prompt and tool onto
46+
`buildInstructions`.

‎ponytail-mcp/index.js‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#!/usr/bin/env node
2+
// Ponytail MCP server: serves the lazy-senior-dev ruleset over stdio as a
3+
// prompt (user-invoked) and a tool (for hosts that pull context via tools).
4+
// It does NOT replace the always-on adapters; it's the clean option for hosts
5+
// whose only injection point is the prompt menu (see #70).
6+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8+
import { z } from "zod";
9+
10+
import { MODES, buildInstructions, resolveMode } from "./instructions.js";
11+
12+
const server = new McpServer({ name: "ponytail", version: "0.1.0" });
13+
14+
const modeArg = z
15+
.enum(MODES)
16+
.optional()
17+
.describe("Ponytail intensity: lite, full, or ultra. Omit for the configured default.");
18+
19+
server.registerPrompt(
20+
"ponytail",
21+
{
22+
title: "Ponytail mode",
23+
description: "Lazy senior dev instructions: YAGNI, stdlib first, the smallest correct change.",
24+
argsSchema: { mode: modeArg },
25+
},
26+
({ mode }) => ({
27+
messages: [{ role: "user", content: { type: "text", text: buildInstructions(mode) } }],
28+
}),
29+
);
30+
31+
server.registerTool(
32+
"ponytail_instructions",
33+
{
34+
title: "Ponytail instructions",
35+
description: "Return the Ponytail ruleset for the given intensity (lite, full, or ultra).",
36+
inputSchema: { mode: modeArg },
37+
outputSchema: { mode: z.string(), instructions: z.string() },
38+
annotations: { readOnlyHint: true, openWorldHint: false },
39+
},
40+
({ mode }) => {
41+
const resolvedMode = resolveMode(mode);
42+
const instructions = buildInstructions(resolvedMode);
43+
const structuredContent = { mode: resolvedMode, instructions };
44+
return { content: [{ type: "text", text: instructions }], structuredContent };
45+
},
46+
);
47+
48+
await server.connect(new StdioServerTransport());

‎ponytail-mcp/instructions.js‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Pure instruction selection for the Ponytail MCP server. No MCP/SDK imports,
2+
// so this stays unit-testable on its own. Reuses the same builder the Claude
3+
// hooks and Pi extension use, so every host emits identical rules.
4+
import { createRequire } from "node:module";
5+
6+
const require = createRequire(import.meta.url);
7+
const { getPonytailInstructions } = require("../hooks/ponytail-instructions.js");
8+
const { getDefaultMode, normalizeMode } = require("../hooks/ponytail-config.js");
9+
10+
// The three intensities the server offers. "off" has no instructions to serve.
11+
export const MODES = ["lite", "full", "ultra"];
12+
13+
// Resolve a requested mode to a runtime intensity. Unknown, empty, or "off"
14+
// falls back to the configured default, then to "full".
15+
// ponytail: keep the surface to these three; "off"/"review" aren't served here.
16+
export function resolveMode(requested) {
17+
const asked = normalizeMode(requested);
18+
if (asked && asked !== "off") return asked;
19+
20+
const fallback = normalizeMode(getDefaultMode());
21+
return fallback && fallback !== "off" ? fallback : "full";
22+
}
23+
24+
export function buildInstructions(requested) {
25+
return getPonytailInstructions(resolveMode(requested));
26+
}

‎ponytail-mcp/package.json‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "ponytail-mcp",
3+
"version": "0.1.0",
4+
"description": "MCP server that serves Ponytail's lazy-senior-dev instructions as a prompt and a tool.",
5+
"type": "module",
6+
"license": "MIT",
7+
"bin": { "ponytail-mcp": "./index.js" },
8+
"scripts": { "test": "node --test ./test/*.test.js" },
9+
"dependencies": {
10+
"@modelcontextprotocol/sdk": "^1.19.0",
11+
"zod": "^3.23.0"
12+
}
13+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import { MODES, resolveMode, buildInstructions } from "../instructions.js";
5+
6+
test("resolveMode keeps valid intensities", () => {
7+
for (const mode of MODES) assert.equal(resolveMode(mode), mode);
8+
});
9+
10+
test("resolveMode falls back to a runtime intensity for off/unknown/empty", () => {
11+
// PONYTAIL_DEFAULT_MODE could be anything in CI, so just assert the contract:
12+
// never returns "off", "review", or junk — always one of the served modes.
13+
for (const input of ["off", "review", "nonsense", "", undefined, null]) {
14+
assert.ok(MODES.includes(resolveMode(input)), `resolveMode(${input}) must be a served mode`);
15+
}
16+
});
17+
18+
test("buildInstructions returns the ruleset tagged with the resolved mode", () => {
19+
const text = buildInstructions("ultra");
20+
assert.match(text, /PONYTAIL MODE ACTIVE/);
21+
assert.match(text, /ultra/);
22+
});

0 commit comments

Comments
 (0)