dotfiles/.agents/mcp/index.ts

201 lines
6.6 KiB
JavaScript

#!/usr/bin/env node
/**
* all-agents MCP server — shared agent infrastructure over the Model Context Protocol.
*
* Prompts and tools are auto-discovered from sibling directories:
* ../agents/*.md → slash-command prompts (requires description: frontmatter)
* ../skills/*.md → model-controlled tools (requires description: frontmatter)
*
* Agent/skill bodies are read from disk at invocation time — editing any .md
* file takes effect immediately without restarting the server.
*
* Frontmatter fields:
* description (required) — routing description for the prompt/tool
* toolName (skills only, optional) — override the derived tool name
* default: load_<basename> (e.g. research-methodology.md → load_research-methodology)
*
* Not handled here (stays bespoke):
* hooks/ — MCP has no lifecycle intercept primitive
* AGENTS.md — always-on bootstrap; model needs it before tools/list
*
* Run: node --experimental-strip-types .agents/mcp/index.ts
* Config: ~/.vscode-server/data/User/mcp.json (Copilot),
* ~/.config/opencode/opencode.json (OpenCode global)
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { readFileSync, readdirSync } from "node:fs";
import { basename, resolve } from "node:path";
import { z } from "zod";
const agentsDir = resolve(import.meta.dirname, "../agents");
const skillsDir = resolve(import.meta.dirname, "../skills");
interface ParsedFile {
description: string;
toolName?: string | undefined;
body: string;
}
/** Parse YAML frontmatter and return description, optional toolName, and body. */
function parseFrontmatter(content: string): ParsedFile {
const lines = content.split("\n");
if (lines[0] !== "---") return { description: "", body: content.trim() };
const end = lines.indexOf("---", 1);
if (end === -1) return { description: "", body: content.trim() };
const frontmatter = lines.slice(1, end).join("\n");
const body = lines
.slice(end + 1)
.join("\n")
.trim();
// Simple single-line or quoted-string extraction for description and toolName
const descMatch = frontmatter.match(
/^description:\s*['"]?([\s\S]*?)['"]?\s*$/m,
);
const toolMatch = frontmatter.match(/^toolName:\s*['"]?([^'"]+)['"]?\s*$/m);
// Handle multi-line description values (block scalar or wrapped string)
let description = "";
if (descMatch) {
// If the match includes a leading quote, strip matching quotes
const raw = frontmatter.match(/^description:\s*(['"])([\s\S]*?)\1\s*$/m);
description = raw ? raw[2]?.trim() ?? '' : descMatch[1]?.trim() ?? '';
}
return {
description,
toolName: toolMatch?.[1]?.trim(),
body,
};
}
function stripLocalBlocks(body: string): string {
return body.replace(/<!-- @local -->[\s\S]*?<!-- @endlocal -->\n?/g, "");
}
function stripCloudBlocks(body: string): string {
return body.replace(/<!-- @cloud -->[\s\S]*?<!-- @endcloud -->\n?/g, "");
}
/**
* Returns 'local' when the MCP client identifies as `opencode` (local-model
* harness), 'cloud' for any other client (Copilot / VS Code etc.).
*/
function getClientProfile(): "local" | "cloud" {
const info = server.server.getClientVersion();
return info?.name === "opencode" ? "local" : "cloud";
}
function applyClientProfile(body: string): string {
return getClientProfile() === "local"
? stripCloudBlocks(body)
: stripLocalBlocks(body);
}
const server = new McpServer({ name: "all-agents", version: "1.0.0" });
// ── Prompts (auto-discovered from ../agents/*.md) ─────────────────────────────
const agentFiles = readdirSync(agentsDir).filter(
(f) => f.endsWith(".md") && f !== "AGENTS.md",
);
for (const file of agentFiles) {
const name = basename(file, ".md");
const { description, body } = parseFrontmatter(
readFileSync(resolve(agentsDir, file), "utf8"),
);
if (!description) {
process.stderr.write(
`[all-agents] WARNING: ${file} has no description — skipping\n`,
);
continue;
}
const argKey =
name === "orchestrator" ? "goal" : name === "brainstorm" ? "topic" : "task";
const argDesc =
name === "orchestrator"
? "The high-level goal to decompose"
: name === "brainstorm"
? "The problem or decision to brainstorm"
: "The specific task or question";
server.registerPrompt(
name,
{
description,
argsSchema: { [argKey]: z.string().optional().describe(argDesc) },
},
(args: Record<string, string | undefined>) => {
const input = args[argKey];
const agentBody = applyClientProfile(
parseFrontmatter(readFileSync(resolve(agentsDir, file), "utf8")).body,
);
return {
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: input ? `${agentBody}\n\n${input}` : agentBody,
},
},
],
};
},
);
}
// ── Tools (auto-discovered from ../skills/*.md) ───────────────────────────────
const skillFiles = readdirSync(skillsDir).filter((f) => f.endsWith(".md"));
for (const file of skillFiles) {
const name = basename(file, ".md");
const { description, toolName } = parseFrontmatter(
readFileSync(resolve(skillsDir, file), "utf8"),
);
if (!description) {
process.stderr.write(
`[all-agents] WARNING: ${file} has no description — skipping\n`,
);
continue;
}
server.registerTool(toolName ?? `load_${name}`, { description }, () => ({
content: [
{
type: "text" as const,
text: parseFrontmatter(readFileSync(resolve(skillsDir, file), "utf8"))
.body,
},
],
}));
}
// ── Resources (no-op to satisfy resources/list) ──────────────────────────────
server.registerResource(
"noop",
"noop://noop",
{ description: "No-op resource (satisfies resources/list)" },
() => ({
contents: [{ uri: "noop://noop", mimeType: "text/plain", text: "" }],
}),
);
// ── Connect ───────────────────────────────────────────────────────────────────
const transport = new StdioServerTransport();
try {
await server.connect(transport);
} catch (err) {
process.stderr.write(
`MCP connect failed: ${err instanceof Error ? err.message : String(err)}\n`,
);
process.exit(1);
}