@ds9-solutions/mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Comando `init`: configura o servidor `ds9` na IDE do usuário e adiciona as instruções ao
3
+ * `AGENTS.md` do projeto.
4
+ *
5
+ * Por que existe: cada IDE guarda a configuração de MCP em um arquivo/formato próprio. Automatizar
6
+ * isso elimina o principal atrito de adoção (editar JSON à mão em cada ferramenta).
7
+ */
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { dirname, join } from "node:path";
11
+ export const PACKAGE_NAME = "@ds9-solutions/mcp";
12
+ export const SERVER_NAME = "ds9";
13
+ // Para suportar uma IDE nova basta acrescentar uma linha aqui, sem alterar a lógica abaixo.
14
+ export const TARGETS = [
15
+ { id: "claude-code", label: "Claude Code (.mcp.json)", file: ".mcp.json", key: "mcpServers" },
16
+ {
17
+ id: "cursor",
18
+ label: "Cursor (.cursor/mcp.json)",
19
+ file: join(".cursor", "mcp.json"),
20
+ key: "mcpServers",
21
+ },
22
+ {
23
+ id: "vscode",
24
+ label: "VS Code / GitHub Copilot (.vscode/mcp.json)",
25
+ file: join(".vscode", "mcp.json"),
26
+ key: "servers",
27
+ stdioType: true,
28
+ },
29
+ {
30
+ id: "gemini",
31
+ label: "Gemini CLI (.gemini/settings.json)",
32
+ file: join(".gemini", "settings.json"),
33
+ key: "mcpServers",
34
+ },
35
+ {
36
+ id: "windsurf",
37
+ label: "Windsurf (~/.codeium/windsurf/mcp_config.json)",
38
+ file: join(homedir(), ".codeium", "windsurf", "mcp_config.json"),
39
+ global: true,
40
+ key: "mcpServers",
41
+ },
42
+ ];
43
+ /**
44
+ * Monta a entrada do servidor: como a IDE deve iniciá-lo (`npx -y @ds9-solutions/mcp`).
45
+ * `platform` é parâmetro apenas para permitir testar o comportamento do Windows em qualquer SO.
46
+ */
47
+ export function serverEntry(target, platform = process.platform) {
48
+ // No Windows, vários hosts não conseguem executar `npx` diretamente (ele é um script .cmd);
49
+ // passar por `cmd /c` resolve.
50
+ const base = platform === "win32"
51
+ ? { command: "cmd", args: ["/c", "npx", "-y", PACKAGE_NAME] }
52
+ : { command: "npx", args: ["-y", PACKAGE_NAME] };
53
+ return target.stdioType ? { type: "stdio", ...base } : base;
54
+ }
55
+ /**
56
+ * Faz merge da entrada `ds9` no JSON existente, sem tocar nos demais servidores do usuário.
57
+ * É idempotente: rodar `init` duas vezes produz exatamente o mesmo resultado (sem duplicar).
58
+ */
59
+ export function mergeServerConfig(existing, target, platform) {
60
+ const current = (existing[target.key] ?? {});
61
+ return {
62
+ ...existing,
63
+ [target.key]: { ...current, [SERVER_NAME]: serverEntry(target, platform) },
64
+ };
65
+ }
66
+ // Marcadores HTML (invisíveis no Markdown renderizado) delimitam o bloco gerenciado por nós. É por
67
+ // eles que sabemos o que atualizar numa nova execução sem encostar no resto do AGENTS.md.
68
+ const BLOCK_START = "<!-- ds9-mcp:start -->";
69
+ const BLOCK_END = "<!-- ds9-mcp:end -->";
70
+ // Instrução lida pelo agente. É o que faz hosts sem skills nativas consultarem o MCP: o AGENTS.md
71
+ // é lido automaticamente por quase todos eles.
72
+ export const AGENTS_BLOCK = `${BLOCK_START}
73
+ ## Diretrizes DS9 (MCP \`ds9\`)
74
+
75
+ Este projeto segue os padrões oficiais da DS9, disponíveis via servidor MCP \`ds9\`.
76
+ Antes de implementar, revisar ou testar código:
77
+
78
+ 1. Chame a tool \`recommend_for_task\` com a descrição da tarefa.
79
+ 2. Leia as skills e agentes recomendados com \`get_skill\` / \`get_agent\` e siga as diretrizes.
80
+ 3. Use \`search_guidelines\` para dúvidas pontuais.
81
+ ${BLOCK_END}`;
82
+ /**
83
+ * Insere o bloco DS9 num AGENTS.md ou, se já existir, substitui apenas o trecho entre os
84
+ * marcadores (assim novas versões do texto chegam ao rodar `init` de novo), preservando o resto.
85
+ */
86
+ export function upsertAgentsBlock(existing) {
87
+ const start = existing.indexOf(BLOCK_START);
88
+ const end = existing.indexOf(BLOCK_END);
89
+ if (start !== -1 && end > start) {
90
+ return existing.slice(0, start) + AGENTS_BLOCK + existing.slice(end + BLOCK_END.length);
91
+ }
92
+ // Primeira vez: acrescenta ao final, separado do conteúdo anterior por uma linha em branco.
93
+ const sep = existing.length === 0 ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
94
+ return `${existing}${sep}${AGENTS_BLOCK}\n`;
95
+ }
96
+ /** Executa o `init` completo para uma IDE: grava a configuração do MCP e (opcional) o AGENTS.md. */
97
+ export function runInit(target, projectDir, opts = {}) {
98
+ const configFile = target.global ? target.file : join(projectDir, target.file);
99
+ let existing = {};
100
+ if (existsSync(configFile)) {
101
+ const raw = readFileSync(configFile, "utf8").trim();
102
+ if (raw) {
103
+ try {
104
+ existing = JSON.parse(raw);
105
+ }
106
+ catch {
107
+ // Arquivos como o do VS Code aceitam comentários (JSONC). Reescrever um arquivo que não
108
+ // conseguimos ler apagaria a configuração do usuário, então preferimos parar e orientar.
109
+ throw new Error(`${configFile} não é JSON puro (comentários?). Adicione manualmente a entrada "${SERVER_NAME}".`);
110
+ }
111
+ }
112
+ }
113
+ mkdirSync(dirname(configFile), { recursive: true });
114
+ writeFileSync(configFile, JSON.stringify(mergeServerConfig(existing, target), null, 2) + "\n");
115
+ const result = { configFile };
116
+ if (opts.agentsMd !== false) {
117
+ const agentsFile = join(projectDir, "AGENTS.md");
118
+ const current = existsSync(agentsFile) ? readFileSync(agentsFile, "utf8") : "";
119
+ writeFileSync(agentsFile, upsertAgentsBlock(current));
120
+ result.agentsFile = agentsFile;
121
+ }
122
+ return result;
123
+ }
@@ -0,0 +1,88 @@
1
+ // Palavras sem valor de busca (PT/EN). Sem isso, "para" ou "the" pontuariam em tudo.
2
+ const STOPWORDS = new Set([
3
+ "de",
4
+ "da",
5
+ "do",
6
+ "a",
7
+ "o",
8
+ "e",
9
+ "para",
10
+ "com",
11
+ "em",
12
+ "um",
13
+ "uma",
14
+ "the",
15
+ "and",
16
+ "for",
17
+ "to",
18
+ ]);
19
+ /**
20
+ * Normaliza o texto em tokens comparáveis: minúsculas, sem acentos ("segurança" casa com
21
+ * "seguranca"), separado por qualquer caractere não alfanumérico, sem stopwords e sem tokens de
22
+ * 1 letra.
23
+ */
24
+ export function tokenize(text) {
25
+ return text
26
+ .toLowerCase()
27
+ .normalize("NFD")
28
+ .replace(/[̀-ͯ]/g, "") // remove os acentos separados pelo NFD
29
+ .split(/[^a-z0-9]+/)
30
+ .filter((t) => t.length > 1 && !STOPWORDS.has(t));
31
+ }
32
+ /**
33
+ * Soma quantas vezes cada token da consulta aparece no texto. Cada token conta no máximo 5 vezes,
34
+ * para que um texto repetitivo não vença só por insistir numa palavra.
35
+ */
36
+ function count(haystack, tokens) {
37
+ if (!tokens.length)
38
+ return 0;
39
+ const freq = new Map();
40
+ for (const t of tokenize(haystack))
41
+ freq.set(t, (freq.get(t) ?? 0) + 1);
42
+ return tokens.reduce((acc, t) => acc + Math.min(freq.get(t) ?? 0, 5), 0);
43
+ }
44
+ /**
45
+ * Ranking por metadados (id, nome e descrição) — usado por `list_*` e `recommend_for_task`.
46
+ * Id/nome pesam 3 e descrição pesa 2 (a descrição é escrita justamente para dizer "quando usar"),
47
+ * e entradas sem nenhum acerto são descartadas.
48
+ */
49
+ export function rankByMetadata(entries, query, kind) {
50
+ const tokens = tokenize(query);
51
+ return entries
52
+ .filter((e) => !kind || e.kind === kind)
53
+ .map((entry) => ({
54
+ entry,
55
+ score: count(`${entry.id} ${entry.name}`, tokens) * 3 + count(entry.description, tokens) * 2,
56
+ }))
57
+ .filter((s) => s.score > 0)
58
+ .sort((a, b) => b.score - a.score);
59
+ }
60
+ /**
61
+ * Busca no corpo completo, linha a linha — usado por `search_guidelines`.
62
+ * Devolve linhas (e não arquivos inteiros) para economizar contexto do LLM: ele recebe só o
63
+ * trecho e pode pedir o arquivo completo com `get_skill`/`get_agent` se precisar.
64
+ */
65
+ export function searchBodies(entries, query, limit = 8) {
66
+ const tokens = tokenize(query);
67
+ if (!tokens.length)
68
+ return [];
69
+ const hits = [];
70
+ for (const entry of entries) {
71
+ entry.body.split(/\r?\n/).forEach((line, i) => {
72
+ const score = count(line, tokens);
73
+ if (score > 0 && line.trim()) {
74
+ hits.push({
75
+ kind: entry.kind,
76
+ id: entry.id,
77
+ line: i + 1,
78
+ text: line.trim().slice(0, 300), // limita linhas gigantes
79
+ score,
80
+ });
81
+ }
82
+ });
83
+ }
84
+ return hits
85
+ .sort((a, b) => b.score - a.score)
86
+ .slice(0, limit)
87
+ .map(({ score: _score, ...rest }) => rest); // o score é interno, não vai para o cliente
88
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Definição do servidor MCP: registra as tools, prompts e resources que a IDE/LLM enxerga.
3
+ *
4
+ * Por que três formas de expor o mesmo conteúdo: cada host MCP suporta um subconjunto.
5
+ * - tools -> o LLM chama sozinho (universal; é o caminho principal);
6
+ * - prompts -> o usuário aciona manualmente, vira slash command em alguns hosts;
7
+ * - resources -> o usuário anexa o conteúdo ao chat (ex.: via `@`) em alguns hosts.
8
+ */
9
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
10
+ import { z } from "zod";
11
+ import { rankByMetadata, searchBodies } from "./search.js";
12
+ // Enviado ao host no handshake. Hosts sem suporte nativo a skills/agentes dependem disto (e do
13
+ // bloco no AGENTS.md) para saber que devem consultar o servidor antes de implementar.
14
+ const INSTRUCTIONS = [
15
+ "Servidor de diretrizes oficiais da DS9 (skills por tecnologia e subagentes especializados).",
16
+ "Antes de implementar, revisar ou testar código, chame `recommend_for_task` com a descrição da tarefa",
17
+ "e depois `get_skill` / `get_agent` para ler as diretrizes recomendadas e segui-las.",
18
+ ].join(" ");
19
+ // Monta a resposta padrão de uma tool. `isError` sinaliza ao LLM que a chamada falhou, mas sem
20
+ // derrubar o protocolo (erro de uso != erro do servidor).
21
+ const text = (t, isError = false) => ({
22
+ content: [{ type: "text", text: t }],
23
+ isError,
24
+ });
25
+ /**
26
+ * Cria o servidor sobre uma lista de entradas já carregada. Receber as entradas por parâmetro
27
+ * (em vez de ler do disco aqui) permite testar o servidor com conteúdo controlado.
28
+ */
29
+ export function createServer(entries, version = "0.0.0") {
30
+ const server = new McpServer({ name: "ds9", version }, { instructions: INSTRUCTIONS });
31
+ // Helpers de consulta sobre as entradas.
32
+ const byKind = (kind) => entries.filter((e) => e.kind === kind);
33
+ const find = (kind, id) => entries.find((e) => e.kind === kind && e.id === id);
34
+ const render = (e) => `# ${e.kind === "skill" ? "Skill" : "Agente"}: ${e.id}\n\n> ${e.description}\n\n${e.body}`;
35
+ const listing = (items) => items.map((e) => `- ${e.id}: ${e.description}`).join("\n") || "(nenhum resultado)";
36
+ // Skills e agentes têm exatamente a mesma API (list/get/prompt/resource). Em vez de duplicar o
37
+ // código, registramos os dois em um laço; só mudam os nomes e os rótulos.
38
+ for (const [kind, plural, label] of [
39
+ ["skill", "skills", "skill"],
40
+ ["agent", "agents", "agente"],
41
+ ]) {
42
+ // list_skills / list_agents — descoberta: o LLM vê o que existe sem gastar contexto com o corpo.
43
+ server.registerTool(`list_${plural}`, {
44
+ description: `Lista ${plural === "skills" ? "as skills" : "os agentes"} DS9 disponíveis (id e descrição). Opcionalmente filtra por termo.`,
45
+ inputSchema: { query: z.string().optional().describe("Termo para filtrar") },
46
+ annotations: { readOnlyHint: true }, // nunca altera nada; hosts podem executar sem pedir confirmação
47
+ }, async ({ query }) => {
48
+ const items = query
49
+ ? rankByMetadata(byKind(kind), query).map((s) => s.entry)
50
+ : byKind(kind);
51
+ return text(listing(items));
52
+ });
53
+ // get_skill / get_agent — leitura do conteúdo completo. Em id inexistente, lista os válidos
54
+ // para o LLM se corrigir na próxima chamada, sem precisar de uma ida extra a `list_*`.
55
+ server.registerTool(`get_${kind}`, {
56
+ description: `Retorna o conteúdo completo ${kind === "skill" ? "de uma skill" : "de um agente"} DS9 pelo id.`,
57
+ inputSchema: { name: z.string().describe(`Id do ${label} (veja list_${plural})`) },
58
+ annotations: { readOnlyHint: true },
59
+ }, async ({ name }) => {
60
+ const entry = find(kind, name);
61
+ if (!entry) {
62
+ return text(`${label} "${name}" não encontrado. Disponíveis:\n${listing(byKind(kind))}`, true);
63
+ }
64
+ return text(render(entry));
65
+ });
66
+ // Um prompt por entrada (ex.: `skill-nestjs`). Fica estático porque o catálogo é conhecido na
67
+ // inicialização do servidor.
68
+ for (const entry of byKind(kind)) {
69
+ server.registerPrompt(`${kind}-${entry.id}`, { description: entry.description }, () => ({
70
+ messages: [{ role: "user", content: { type: "text", text: render(entry) } }],
71
+ }));
72
+ }
73
+ // Resources via template `ds9://skills/{id}`: o host lista as URIs disponíveis (`list`) e lê
74
+ // uma específica sob demanda.
75
+ server.registerResource(plural, new ResourceTemplate(`ds9://${plural}/{id}`, {
76
+ list: async () => ({
77
+ resources: byKind(kind).map((e) => ({
78
+ uri: `ds9://${plural}/${e.id}`,
79
+ name: e.id,
80
+ description: e.description,
81
+ mimeType: "text/markdown",
82
+ })),
83
+ }),
84
+ }), {
85
+ description: `Conteúdo ${plural === "skills" ? "das skills" : "dos agentes"} DS9`,
86
+ mimeType: "text/markdown",
87
+ }, async (uri, { id }) => {
88
+ const entry = find(kind, String(id));
89
+ if (!entry)
90
+ throw new Error(`${label} "${String(id)}" não encontrado`);
91
+ return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: render(entry) }] };
92
+ });
93
+ }
94
+ // search_guidelines — para dúvidas pontuais ("como nomear migrations?") sem ler arquivos inteiros.
95
+ server.registerTool("search_guidelines", {
96
+ description: "Busca um termo no conteúdo de todas as skills e agentes DS9 e devolve as linhas mais relevantes.",
97
+ inputSchema: { query: z.string().min(2), limit: z.number().int().min(1).max(25).optional() },
98
+ annotations: { readOnlyHint: true },
99
+ }, async ({ query, limit }) => {
100
+ const hits = searchBodies(entries, query, limit ?? 8);
101
+ return text(hits.length
102
+ ? hits.map((h) => `[${h.kind}:${h.id}:L${h.line}] ${h.text}`).join("\n")
103
+ : "Nenhum trecho encontrado.");
104
+ });
105
+ // recommend_for_task — é a "ativação automática" possível via MCP: compensa a falta de roteamento
106
+ // por `description` que os hosts nativos de skills têm. O LLM descreve a tarefa e recebe quais
107
+ // skills/agentes ler. Sem correspondência, devolve o catálogo para ele escolher sozinho.
108
+ server.registerTool("recommend_for_task", {
109
+ description: "Dada a descrição de uma tarefa de desenvolvimento, recomenda as skills e agentes DS9 que devem ser lidos e seguidos. Chame ANTES de implementar.",
110
+ inputSchema: { task: z.string().min(3).describe("Descrição da tarefa") },
111
+ annotations: { readOnlyHint: true },
112
+ }, async ({ task }) => {
113
+ const skills = rankByMetadata(entries, task, "skill")
114
+ .slice(0, 5)
115
+ .map((s) => s.entry);
116
+ const agents = rankByMetadata(entries, task, "agent")
117
+ .slice(0, 3)
118
+ .map((s) => s.entry);
119
+ if (!skills.length && !agents.length) {
120
+ return text(`Nenhuma correspondência direta. Skills disponíveis:\n${listing(byKind("skill"))}\n\nAgentes:\n${listing(byKind("agent"))}`);
121
+ }
122
+ return text(`Skills recomendadas (use get_skill):\n${listing(skills)}\n\nAgentes recomendados (use get_agent):\n${listing(agents)}`);
123
+ });
124
+ return server;
125
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Testes do pacote, com o runner nativo do Node (`node:test`).
3
+ *
4
+ * Por que `node:test`: evita configurar Jest + ts-jest com ESM e não adiciona dependência.
5
+ * Os testes usam o conteúdo REAL do plugin, então também falham se alguém quebrar o frontmatter
6
+ * de uma skill ou agente (ex.: remover a `description`).
7
+ */
8
+ import { test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
14
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
15
+ import { loadEntries, parseFrontmatter } from "../src/content.js";
16
+ import { createServer } from "../src/server.js";
17
+ import { rankByMetadata, searchBodies } from "../src/search.js";
18
+ import { TARGETS, mergeServerConfig, runInit, upsertAgentsBlock } from "../src/init.js";
19
+ const entries = loadEntries();
20
+ /**
21
+ * Conecta um cliente MCP de verdade ao servidor, em memória (sem processo nem stdio). Assim os
22
+ * testes exercitam o protocolo completo — o mesmo caminho que uma IDE usaria.
23
+ */
24
+ async function connect() {
25
+ const [a, b] = InMemoryTransport.createLinkedPair();
26
+ const client = new Client({ name: "test", version: "0" });
27
+ await Promise.all([createServer(entries).connect(a), client.connect(b)]);
28
+ return client;
29
+ }
30
+ // Extrai o texto da primeira parte de uma resposta de tool.
31
+ const firstText = (r) => r.content[0].text;
32
+ test("parseFrontmatter separa frontmatter e corpo", () => {
33
+ const { data, body } = parseFrontmatter("---\nname: x\ndescription: d\n---\n\n# Corpo\n");
34
+ assert.equal(data.name, "x");
35
+ assert.equal(body, "# Corpo");
36
+ });
37
+ test("loader carrega skills e agentes reais", () => {
38
+ assert.ok(entries.some((e) => e.kind === "skill" && e.id === "nestjs"));
39
+ assert.ok(entries.some((e) => e.kind === "agent" && e.id === "code-reviewer"));
40
+ // A description alimenta o ranking do `recommend_for_task`; sem ela a entrada fica invisível.
41
+ assert.ok(entries.every((e) => e.description.length > 0));
42
+ });
43
+ test("rankByMetadata prioriza a skill relevante", () => {
44
+ const top = rankByMetadata(entries, "criar Dockerfile multi-stage", "skill")[0];
45
+ assert.equal(top.entry.id, "docker");
46
+ });
47
+ test("searchBodies encontra trechos", () => {
48
+ assert.ok(searchBodies(entries, "migration").length > 0);
49
+ // "a" é stopword/1 letra: a consulta vira vazia e não deve devolver tudo.
50
+ assert.deepEqual(searchBodies(entries, "a"), []);
51
+ });
52
+ test("tools: list, get, erro em id inexistente, recommend", async () => {
53
+ const client = await connect();
54
+ const tools = (await client.listTools()).tools.map((t) => t.name);
55
+ for (const n of [
56
+ "list_skills",
57
+ "get_skill",
58
+ "list_agents",
59
+ "get_agent",
60
+ "search_guidelines",
61
+ "recommend_for_task",
62
+ ]) {
63
+ assert.ok(tools.includes(n), n);
64
+ }
65
+ assert.match(firstText(await client.callTool({ name: "list_skills", arguments: {} })), /hexagonal/);
66
+ assert.match(firstText(await client.callTool({ name: "get_skill", arguments: { name: "docker" } })), /Skill: docker/);
67
+ // Id inexistente deve ser um erro "de uso" (isError), não uma exceção do protocolo.
68
+ const missing = await client.callTool({ name: "get_agent", arguments: { name: "nope" } });
69
+ assert.equal(missing.isError, true);
70
+ assert.match(firstText(await client.callTool({
71
+ name: "recommend_for_task",
72
+ arguments: { task: "revisar segurança de código" },
73
+ })), /security-reviewer/);
74
+ });
75
+ test("prompts e resources expostos", async () => {
76
+ const client = await connect();
77
+ assert.ok((await client.listPrompts()).prompts.some((p) => p.name === "skill-nestjs"));
78
+ const res = await client.listResources();
79
+ assert.ok(res.resources.some((r) => r.uri === "ds9://skills/typeorm"));
80
+ const read = await client.readResource({ uri: "ds9://agents/test-writer" });
81
+ assert.match(read.contents[0].text, /Agente: test-writer/);
82
+ });
83
+ test("mergeServerConfig preserva outros servidores e é idempotente", () => {
84
+ const cursor = TARGETS.find((t) => t.id === "cursor");
85
+ const once = mergeServerConfig({ mcpServers: { outro: { command: "x" } } }, cursor, "linux");
86
+ // O servidor que o usuário já tinha ("outro") não pode ser perdido.
87
+ assert.deepEqual(Object.keys(once.mcpServers).sort(), ["ds9", "outro"]);
88
+ // Rodar de novo não pode mudar nada.
89
+ assert.deepEqual(mergeServerConfig(once, cursor, "linux"), once);
90
+ // VS Code usa `servers` + `type: stdio`; no Windows o comando passa por `cmd /c`.
91
+ const vscode = TARGETS.find((t) => t.id === "vscode");
92
+ const v = mergeServerConfig({}, vscode, "win32");
93
+ assert.equal(v.servers.ds9.type, "stdio");
94
+ assert.equal(v.servers.ds9.command, "cmd");
95
+ });
96
+ test("upsertAgentsBlock insere uma vez e atualiza sem duplicar", () => {
97
+ const once = upsertAgentsBlock("# Meu projeto\n");
98
+ assert.match(once, /# Meu projeto/); // conteúdo prévio preservado
99
+ assert.equal(upsertAgentsBlock(once), once); // segunda execução não muda nada
100
+ assert.equal(once.match(/ds9-mcp:start/g)?.length, 1); // bloco nunca duplica
101
+ });
102
+ test("runInit grava config e AGENTS.md; recusa JSON inválido", () => {
103
+ // Diretório temporário: o teste nunca toca em arquivos reais do usuário.
104
+ const dir = mkdtempSync(join(tmpdir(), "ds9-init-"));
105
+ const cursor = TARGETS.find((t) => t.id === "cursor");
106
+ const r = runInit(cursor, dir);
107
+ assert.ok(JSON.parse(readFileSync(r.configFile, "utf8")).mcpServers.ds9);
108
+ assert.match(readFileSync(r.agentsFile, "utf8"), /recommend_for_task/);
109
+ // Arquivo com comentário (JSONC) não pode ser sobrescrito: deve falhar com orientação.
110
+ writeFileSync(join(dir, ".mcp.json"), "{ // comentário\n}");
111
+ assert.throws(() => runInit(TARGETS[0], dir), /não é JSON puro/);
112
+ });
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@ds9-solutions/mcp",
3
+ "version": "0.1.0",
4
+ "description": "Servidor MCP da DS9: expõe skills e subagentes oficiais para qualquer IDE/LLM com suporte a MCP",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "bin": {
8
+ "ds9-mcp": "dist/src/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "content"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "scripts": {
18
+ "sync-content": "node scripts/sync-content.mjs",
19
+ "build": "tsc -p tsconfig.json",
20
+ "format": "prettier --write src test scripts",
21
+ "test": "npm run build && node --test \"dist/test/*.test.js\"",
22
+ "prepack": "npm run sync-content && npm run build"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "dependencies": {
28
+ "@modelcontextprotocol/sdk": "^1.32.1",
29
+ "yaml": "^2.9.1",
30
+ "zod": "^4.6.5"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^26.6.4",
34
+ "prettier": "^3.9.9",
35
+ "typescript": "^7.0.2"
36
+ }
37
+ }