Ctrl K
ComunidadGuías…

tests/contract/mcp-compat.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { afterAll, beforeAll, test } from "bun:test";
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseReferenceGraph, type ReferenceGraph } from "../../src/core/portability";
import {
  importReferenceMemory,
  openLocalStore,
  referenceMemoryCandidates,
} from "../../src/runtime/bun/mcp-stdio";
import { createSqliteDb, openDatabase } from "../../src/runtime/bun/sqlite";
import { provisionWith } from "./harness";

/**
 * Drop-in substitution for `@modelcontextprotocol/server-memory`.
 *
 * The claim being tested is that a user switches by editing one line of MCP
 * configuration: the same nine tool names, the same arguments, the same
 * response shapes, and their existing `memory.json` still there afterwards.
 * So every assertion here is written from the client's side of the wire, and
 * the fixture is that server's own on-disk format rather than a Titen export.
 */
const directory = mkdtempSync(join(tmpdir(), "titen-mcp-compat-"));
const memoryFile = join(directory, "memory.json");
const dbPath = join(directory, "memory.db");

/** Exactly what the reference server writes: one JSON object per line. */
const FIXTURE_LINES = [
  {
    type: "entity",
    name: "billing-service",
    entityType: "service",
    observations: [
      "Handles refund processing and invoice generation for enterprise customers",
      "Owned by the payments team",
    ],
  },
  {
    type: "entity",
    name: "Ada Lovelace",
    entityType: "person",
    observations: ["Leads the payments team", "Prefers asynchronous design reviews"],
  },
  {
    type: "entity",
    name: "checkout-api",
    entityType: "service",
    observations: ["Calls billing-service when an order is cancelled"],
  },
  { type: "entity", name: "quiet-worker", entityType: "service", observations: [] },
  { type: "relation", from: "Ada Lovelace", to: "billing-service", relationType: "maintains" },
  { type: "relation", from: "checkout-api", to: "billing-service", relationType: "depends_on" },
];

const FIXTURE: ReferenceGraph = parseReferenceGraph(
  FIXTURE_LINES.map((line) => JSON.stringify(line)).join("\n"),
);

/**
 * The reference server's entire search: lower-case substring containment over a
 * graph re-read from disk. Reimplemented here so the comparison in the search
 * test is against the real thing rather than against a description of it.
 */
function referenceSearch(graph: ReferenceGraph, query: string): ReferenceGraph {
  const entities = graph.entities.filter((entity) =>
    entity.name.toLowerCase().includes(query.toLowerCase())
    || entity.entityType.toLowerCase().includes(query.toLowerCase())
    || entity.observations.some((observation) =>
      observation.toLowerCase().includes(query.toLowerCase())));
  const names = new Set(entities.map((entity) => entity.name));
  return {
    entities,
    relations: graph.relations.filter((relation) =>
      names.has(relation.from) || names.has(relation.to)),
  };
}

let local: Awaited<ReturnType<typeof openLocalStore>>;
let restrictedKey: string;
let messageId = 0;

interface ToolReply {
  isError?: boolean;
  content: { type: string; text: string }[];
  structuredContent: any;
}

async function rpc(method: string, params: unknown, key?: string) {
  const response = await local.app(new Request("http://127.0.0.1/mcp", {
    method: "POST",
    headers: {
      accept: "application/json",
      authorization: `Bearer ${key ?? local.apiKey}`,
      "content-type": "application/json",
    },
    body: JSON.stringify({ jsonrpc: "2.0", id: (messageId += 1), method, params }),
  }));
  return await response.json() as { result?: any; error?: { code: number; message: string } };
}

async function callTool(name: string, args: unknown = {}, key?: string): Promise<ToolReply> {
  const reply = await rpc("tools/call", { name, arguments: args }, key);
  assert.equal(reply.error, undefined, `${name} returned a JSON-RPC error`);
  return reply.result as ToolReply;
}

/** Every reference tool reports its result twice; a client may read either. */
function toolResult(reply: ToolReply): any {
  assert.equal(reply.content[0]!.type, "text");
  return JSON.parse(reply.content[0]!.text);
}

const readGraph = async (): Promise<ReferenceGraph> =>
  (await callTool("read_graph")).structuredContent;

beforeAll(async () => {
  writeFileSync(memoryFile, `${FIXTURE_LINES.map((line) => JSON.stringify(line)).join("\n")}\n`);
  local = await openLocalStore(dbPath);
  const database = createSqliteDb(openDatabase(dbPath));
  const [organization] = await database.all<{ id: string }>(`SELECT id FROM organizations`);
  const provisioned = await provisionWith(database, {
    orgId: organization!.id,
    scopes: ["mcp:call"],
  });
  restrictedKey = provisioned.key;
});

afterAll(() => {
  local.close();
  rmSync(directory, { recursive: true, force: true });
});

test("the search covers where the reference server actually writes", () => {
  const previous = process.env.MEMORY_FILE_PATH;
  delete process.env.MEMORY_FILE_PATH;
  try {
    const candidates = referenceMemoryCandidates("/work");
    const pkg = "node_modules/@modelcontextprotocol/server-memory/dist";

    // The cwd is a guess — an MCP server launched by a desktop client inherits
    // the client's directory — so it must not be the only place we look.
    assert.ok(candidates.includes("/work/memory.jsonl"));
    assert.ok(candidates.includes("/work/memory.json"));

    // Where the reference server writes with MEMORY_FILE_PATH unset: beside its
    // own module. Missing this is what made a switch import zero and say nothing.
    assert.ok(candidates.includes(`/work/${pkg}/memory.jsonl`));
    assert.ok(candidates.includes(`/work/${pkg}/memory.json`));

    // Explicit configuration still wins outright, relative or absolute.
    process.env.MEMORY_FILE_PATH = "graph.jsonl";
    assert.deepEqual(referenceMemoryCandidates("/work"), ["/work/graph.jsonl"]);
    process.env.MEMORY_FILE_PATH = "/elsewhere/graph.jsonl";
    assert.deepEqual(referenceMemoryCandidates("/work"), ["/elsewhere/graph.jsonl"]);
  } finally {
    if (previous === undefined) delete process.env.MEMORY_FILE_PATH;
    else process.env.MEMORY_FILE_PATH = previous;
  }
});

test("an existing memory.json is imported without data loss", async () => {
  const imported = await importReferenceMemory(local, memoryFile, `${dbPath}.imported`);
  assert.deepEqual(imported, { entities: 4, relations: 2 });

  // Byte-for-byte the same graph: names, types, observation text and order.
  assert.deepEqual(await readGraph(), FIXTURE);

  // A second start must not re-import: that would resurrect deleted entities.
  assert.equal(
    await importReferenceMemory(local, memoryFile, `${dbPath}.imported`),
    undefined,
  );
  assert.deepEqual(await readGraph(), FIXTURE);
});

test("the installed entry point adopts a memory.json in the working directory", async () => {
  const home = join(directory, "adopt-home");
  const work = join(directory, "adopt-work");
  mkdirSync(home);
  mkdirSync(work);
  writeFileSync(join(work, "memory.json"), [
    JSON.stringify({
      type: "entity",
      name: "billing-service",
      entityType: "service",
      observations: ["Handles refunds"],
    }),
    JSON.stringify({ type: "relation", from: "checkout-api", to: "billing-service", relationType: "depends_on" }),
  ].join("\n"));

  const child = Bun.spawn({
    cmd: ["bun", join(import.meta.dir, "../../src/runtime/bun/cli.ts"), "mcp"],
    cwd: work,
    // Deliberately not `...process.env`: this is the no-configuration case.
    env: { PATH: process.env.PATH ?? "", HOME: home },
    stdin: "pipe",
    stdout: "pipe",
    stderr: "pipe",
  });
  child.stdin.write([
    { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "compat", version: "1" } } },
    { jsonrpc: "2.0", method: "notifications/initialized" },
    { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "read_graph", arguments: {} } },
  ].map((message) => `${JSON.stringify(message)}\n`).join(""));
  child.stdin.end();
  const [exitCode, stdout, stderr] = await Promise.all([
    Promise.race([child.exited, Bun.sleep(30_000).then(() => -1)]),
    new Response(child.stdout).text(),
    new Response(child.stderr).text(),
  ]);

  assert.equal(exitCode, 0, stderr);
  // Progress belongs on stderr; stdout is the MCP transport and must stay JSON.
  assert.match(stderr, /imported 1 entities and 1 relations/);
  const replies = stdout.split("\n").filter(Boolean).map((line) => JSON.parse(line));
  assert.deepEqual(replies.at(-1).result.structuredContent, {
    entities: [{ name: "billing-service", entityType: "service", observations: ["Handles refunds"] }],
    relations: [{ from: "checkout-api", to: "billing-service", relationType: "depends_on" }],
  });
}, 60_000);

test("the graph is readable as a resource, the way the reference server serves it", async () => {
  // A client that reads memory://knowledge-graph instead of calling read_graph
  // used to get -32601 on the switch, which is a broken drop-in however well
  // the tool names line up.
  const listed = (await rpc("resources/list", {})).result.resources as {
    uri: string;
    mimeType: string;
  }[];
  const graphResource = listed.find((r) => r.uri === "memory://knowledge-graph");
  assert.ok(graphResource, "memory://knowledge-graph must be listed");
  assert.equal(graphResource.mimeType, "application/json");

  const read = (await rpc("resources/read", { uri: "memory://knowledge-graph" })).result;
  const contents = read.contents as { uri: string; mimeType: string; text: string }[];
  assert.equal(contents.length, 1);
  assert.equal(contents[0]!.uri, "memory://knowledge-graph");

  // Same bytes the read_graph tool returns, so the two paths cannot drift.
  assert.deepEqual(JSON.parse(contents[0]!.text), await readGraph());

  const unknown = await rpc("resources/read", { uri: "memory://nope" });
  assert.ok(unknown.error, "an unknown resource must return an error");
  assert.equal(unknown.error.code, -32602, "an unknown uri is a params error, not a 404");
});

test("the nine reference tool names are served with the reference schemas", async () => {
  const listed = (await rpc("tools/list", {})).result.tools as {
    name: string;
    inputSchema: { properties: Record<string, unknown>; required: string[] };
    annotations: Record<string, boolean>;
  }[];
  const byName = new Map(listed.map((tool) => [tool.name, tool]));

  const expected: Record<string, string[]> = {
    create_entities: ["entities"],
    create_relations: ["relations"],
    add_observations: ["observations"],
    delete_entities: ["entityNames"],
    delete_observations: ["deletions"],
    delete_relations: ["relations"],
    read_graph: [],
    search_nodes: ["query"],
    open_nodes: ["names"],
  };
  for (const [name, required] of Object.entries(expected)) {
    const tool = byName.get(name);
    assert.ok(tool, `${name} must be served`);
    assert.deepEqual(tool.inputSchema.required, required, `${name} argument names`);
    assert.deepEqual(Object.keys(tool.inputSchema.properties), required, `${name} properties`);
  }
  assert.equal(byName.get("search_nodes")!.annotations.readOnlyHint, true);
  assert.equal(byName.get("delete_entities")!.annotations.destructiveHint, true);
  // The Titen tools stay where they were; this is an addition, not a swap.
  assert.ok(byName.has("titen_remember"));
});

test("search_nodes finds what the reference server's substring scan cannot", async () => {
  const query = "which service issues refunds to a customer";

  // The incumbent lower-cases the whole query and asks `String.includes`, so a
  // question phrased as a question matches nothing at all.
  assert.deepEqual(referenceSearch(FIXTURE, query).entities, []);

  const found: ReferenceGraph = (await callTool("search_nodes", { query })).structuredContent;
  assert.equal(
    found.entities[0]?.name,
    "billing-service",
    "ranked retrieval must surface the refund-handling service first",
  );
  assert.deepEqual(
    found.entities[0]!.observations,
    FIXTURE.entities[0]!.observations,
    "a hit carries the whole entity, as the reference shape requires",
  );
  // Relations reach outside the matched set, exactly as the reference does.
  assert.ok(found.relations.some((relation) => relation.to === "billing-service"));

  // An unrelated question must not drag the whole graph back.
  const unrelated: ReferenceGraph =
    (await callTool("search_nodes", { query: "kubernetes cluster autoscaling" })).structuredContent;
  assert.deepEqual(unrelated.entities, []);
});

test("create, add, and open behave as the reference server documents them", async () => {
  const created = await callTool("create_entities", {
    entities: [
      { name: "payments-team", entityType: "team", observations: ["Owns billing-service"] },
      { name: "billing-service", entityType: "service", observations: ["ignored duplicate"] },
    ],
  });
  assert.deepEqual(created.structuredContent.entities.map((entity: any) => entity.name), [
    "payments-team",
  ], "an existing name is skipped and never returned");
  assert.deepEqual(toolResult(created), created.structuredContent.entities);

  const relations = await callTool("create_relations", {
    relations: [
      { from: "payments-team", to: "billing-service", relationType: "owns" },
      { from: "Ada Lovelace", to: "billing-service", relationType: "maintains" },
    ],
  });
  assert.deepEqual(relations.structuredContent.relations, [
    { from: "payments-team", to: "billing-service", relationType: "owns" },
  ], "an existing triple is skipped");

  const added = await callTool("add_observations", {
    observations: [{
      entityName: "billing-service",
      contents: ["Owned by the payments team", "Runs on the shared Bun runtime"],
    }],
  });
  assert.deepEqual(added.structuredContent, {
    results: [{
      entityName: "billing-service",
      addedObservations: ["Runs on the shared Bun runtime"],
    }],
  }, "an observation already present is not added twice");

  const missing = await callTool("add_observations", {
    observations: [{ entityName: "no-such-entity", contents: ["x"] }],
  });
  assert.equal(missing.isError, true);
  assert.match(missing.content[0]!.text, /Entity with name no-such-entity not found/);

  const opened: ReferenceGraph =
    (await callTool("open_nodes", { names: ["payments-team", "no-such-entity"] })).structuredContent;
  assert.deepEqual(opened.entities.map((entity) => entity.name), ["payments-team"]);
  assert.deepEqual(opened.relations, [
    { from: "payments-team", to: "billing-service", relationType: "owns" },
  ]);
});

test("deleting requires the purge capability and then really removes the record", async () => {
  const seeded = await callTool("create_entities", {
    entities: [{ name: "temp-entity", entityType: "service", observations: ["short lived"] }],
  }, restrictedKey);
  assert.notEqual(seeded.isError, true, "writing needs no new capability");

  const refused = await callTool("delete_entities", { entityNames: ["temp-entity"] }, restrictedKey);
  assert.equal(refused.isError, true, "purge must fail closed without its scope");
  assert.match(refused.content[0]!.text, /observations:purge/);

  // The restricted principal's own graph is untouched by the refusal.
  const stillThere = (await callTool("read_graph", {}, restrictedKey)).structuredContent;
  assert.deepEqual(stillThere.entities.map((entity: any) => entity.name), ["temp-entity"]);

  const deleted = await callTool("delete_observations", {
    deletions: [{ entityName: "billing-service", observations: ["Runs on the shared Bun runtime"] }],
  });
  assert.deepEqual(deleted.structuredContent, {
    success: true,
    message: "Observations deleted successfully",
  });
  assert.equal(deleted.content[0]!.text, "Observations deleted successfully");

  await callTool("delete_relations", {
    relations: [{ from: "checkout-api", to: "billing-service", relationType: "depends_on" }],
  });
  const afterRelation = await readGraph();
  assert.ok(!afterRelation.relations.some((relation) => relation.relationType === "depends_on"));

  const removed = await callTool("delete_entities", { entityNames: ["checkout-api"] });
  assert.deepEqual(removed.structuredContent, {
    success: true,
    message: "Entities deleted successfully",
  });
  const graph = await readGraph();
  assert.ok(!graph.entities.some((entity) => entity.name === "checkout-api"));
  assert.deepEqual(
    graph.entities.find((entity) => entity.name === "billing-service")!.observations,
    [
      "Handles refund processing and invoice generation for enterprise customers",
      "Owned by the payments team",
    ],
    "only the deleted observation is gone",
  );

  // Purged evidence must also leave retrieval, not just the graph read.
  const found: ReferenceGraph =
    (await callTool("search_nodes", { query: "order cancelled calls" })).structuredContent;
  assert.ok(!found.entities.some((entity) => entity.name === "checkout-api"));

  // A deleted name is free again: the row is gone, not merely hidden.
  const recreated = await callTool("create_entities", {
    entities: [{ name: "checkout-api", entityType: "service", observations: ["rebuilt"] }],
  });
  assert.deepEqual(recreated.structuredContent.entities.map((entity: any) => entity.name), [
    "checkout-api",
  ]);
});

test("Windows line endings are stored as LF, not rejected as control characters", async () => {
  const created = await callTool("create_entities", {
    entities: [{ name: "windows-runner", entityType: "service", observations: ["Readme line\r\nnext"] }],
  });
  assert.equal(created.isError, undefined);
  assert.deepEqual(created.structuredContent.entities[0].observations, ["Readme line\nnext"]);
  const added = await callTool("add_observations", {
    observations: [{ entityName: "windows-runner", contents: ["Deploys from\r\nthe release branch"] }],
  });
  assert.equal(added.isError, undefined);
  assert.deepEqual(added.structuredContent.results[0].addedObservations, ["Deploys from\nthe release branch"]);
  const lone = await callTool("add_observations", {
    observations: [{ entityName: "windows-runner", contents: ["progress 10%\rprogress 99%"] }],
  });
  assert.equal(lone.isError, true);
  assert.match(lone.content[0]!.text, /carriage return/);
});