tests/integration/mcp-protocol.test.ts
Rama main · réplica de lectura del repositorio original.
import { afterAll, beforeAll, test } from "bun:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createSqliteDb, openDatabase } from "../../src/runtime/bun/sqlite";
import { serve } from "../../src/runtime/bun/server";
import { provisionWith } from "../contract/harness";
import { TITEN_VERSION } from "../../src/core/version";
/**
* MCP protocol compliance, driven the way a client drives it.
*
* These assertions deliberately read the raw HTTP body rather than any Titen
* helper. An earlier version of the endpoint returned the JSON-RPC object nested
* inside Titen's `{ data, meta }` envelope, which no MCP client can parse, and the
* contract test at the time asserted that nested shape and so agreed with the
* bug. Reading the wire is what makes this test able to catch it.
*/
const directory = mkdtempSync(join(tmpdir(), "titen-mcp-"));
let running: Awaited<ReturnType<typeof serve>>;
let key: string;
/** Speaks JSON-RPC over HTTP and returns the parsed body plus the status. */
async function rpc(payload: unknown, headers: Record<string, string> = {}) {
const res = await fetch(`${running.url}/mcp`, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json, text/event-stream",
authorization: `Bearer ${key}`,
...headers,
},
body: JSON.stringify(payload),
});
const text = await res.text();
return {
status: res.status,
requestId: res.headers.get("x-request-id"),
text,
body: text === "" ? null : (JSON.parse(text) as any),
};
}
beforeAll(async () => {
const dbPath = join(directory, "titen.db");
running = await serve({ dbPath, port: 0, hostname: "127.0.0.1", quiet: true, revision: "mcp" });
const provisioned = await provisionWith(createSqliteDb(openDatabase(dbPath)), {
scopes: ["*"],
});
key = provisioned.key;
});
afterAll(async () => {
await running.stop();
rmSync(directory, { recursive: true, force: true });
});
test("the response body is a JSON-RPC object, not a wrapped payload", async () => {
const res = await rpc({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} });
assert.equal(res.status, 200);
// The three properties a client actually depends on.
assert.equal(res.body.jsonrpc, "2.0", "jsonrpc must be a top-level field");
assert.equal(res.body.id, 1, "the id must be echoed at the top level");
assert.ok(res.body.result, "the result must be a top-level field");
// The envelope that used to wrap this must be absent.
assert.equal(res.body.data, undefined, "an MCP reply must not carry Titen's data field");
assert.equal(res.body.meta, undefined, "an MCP reply must not carry Titen's meta field");
});
test("initialize negotiates a protocol revision and names the server", async () => {
const current = await rpc({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "probe", version: "0" } },
});
assert.equal(current.body.result.protocolVersion, "2025-06-18", "a supported revision is echoed");
assert.equal(current.body.result.serverInfo.name, "titen");
assert.equal(current.body.result.serverInfo.version, TITEN_VERSION);
assert.ok(current.body.result.capabilities.tools, "tools capability must be declared");
assert.match(current.body.result.instructions, /titen_project_resolve/);
assert.match(current.body.result.instructions, /titen_compile once/);
assert.match(current.body.result.instructions, /new task or repository scope/);
assert.match(current.body.result.instructions, /untrusted reference data/);
assert.match(current.body.result.instructions, /never capture transcripts or secrets/);
assert.match(current.body.result.instructions, /pass consolidate: true on titen_remember/);
assert.match(current.body.result.instructions, /titen_compile omits a titen_remember write until titen_consolidate/);
assert.match(current.body.result.instructions, /titen_whoami/);
assert.match(current.body.result.instructions, /max_tokens of at least 800/);
assert.match(current.body.result.instructions, /subject id is not a project reference/);
assert.match(current.body.result.instructions, /Trust on remember defaults to asserted/);
assert.ok(current.body.result.instructions.length <= 800);
const latest = await rpc({
jsonrpc: "2.0",
id: 4,
method: "initialize",
params: { protocolVersion: "2025-11-25" },
});
assert.equal(latest.body.result.protocolVersion, "2025-11-25");
// An older client must not be forced onto a newer revision.
const older = await rpc({
jsonrpc: "2.0",
id: 2,
method: "initialize",
params: { protocolVersion: "2024-11-05" },
});
assert.equal(older.body.result.protocolVersion, "2024-11-05");
// An unknown revision falls back to one this server actually implements.
const unknown = await rpc({
jsonrpc: "2.0",
id: 3,
method: "initialize",
params: { protocolVersion: "1999-01-01" },
});
assert.ok(
["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"].includes(
unknown.body.result.protocolVersion,
),
"the fallback must be a revision this server implements",
);
});
test("a notification receives no response body", async () => {
// Sent by every compliant client right after initialize. Answering it is what
// makes a client hang or report a protocol violation.
const res = await rpc({ jsonrpc: "2.0", method: "notifications/initialized" });
assert.equal(res.status, 202, "a notification must be accepted, not answered");
assert.equal(res.text, "", "a notification must receive an empty body");
});
test("ping answers, and the full handshake completes in order", async () => {
await rpc({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18" } });
await rpc({ jsonrpc: "2.0", method: "notifications/initialized" });
const pong = await rpc({ jsonrpc: "2.0", id: 2, method: "ping" });
assert.equal(pong.body.jsonrpc, "2.0");
assert.deepEqual(pong.body.result, {}, "ping returns an empty result");
const tools = await rpc({ jsonrpc: "2.0", id: 3, method: "tools/list" });
assert.ok(Array.isArray(tools.body.result.tools), "tools must be an array");
// Nine native `titen_*` tools plus the nine @modelcontextprotocol/server-memory
// names served for drop-in substitution (#279).
assert.equal(tools.body.result.tools.length, 19, "every ordinary-agent tool must be advertised");
for (const tool of tools.body.result.tools) {
assert.ok(typeof tool.name === "string" && tool.name.length > 0);
assert.ok(typeof tool.description === "string" && tool.description.length > 0);
assert.equal(tool.inputSchema.type, "object", "each tool needs an object schema");
assert.equal(tool.inputSchema.additionalProperties, false);
assert.ok(Array.isArray(tool.inputSchema.required));
for (const property of Object.values(tool.inputSchema.properties) as Array<Record<string, unknown>>)
assert.equal(typeof property.description, "string", `${tool.name} property lacks a description`);
assert.equal(typeof tool.annotations.readOnlyHint, "boolean");
assert.equal(typeof tool.annotations.destructiveHint, "boolean");
assert.equal(typeof tool.annotations.idempotentHint, "boolean");
assert.equal(tool.annotations.openWorldHint, false);
}
const compile = tools.body.result.tools.find((tool: any) => tool.name === "titen_compile");
assert.equal(compile.annotations.readOnlyHint, false, "compile records a context run");
assert.equal(compile.annotations.idempotentHint, false, "compile creates a new context run");
assert.equal(compile.inputSchema.properties.cross_project.type, "boolean");
assert.match(compile.inputSchema.properties.cross_project.description, /context:compile:all/);
const remember = tools.body.result.tools.find((tool: any) => tool.name === "titen_remember");
assert.deepEqual(remember.inputSchema.properties.kind.enum, [
"user_statement", "tool_result", "imported_source", "decision", "system_event",
]);
assert.deepEqual(remember.inputSchema.properties.trust.enum, [
"unverified", "asserted", "verified", "policy_approved",
]);
assert.deepEqual(remember.inputSchema.properties.visibility.enum, [
"private", "team", "organization",
]);
assert.equal(
remember.annotations.idempotentHint,
false,
"remember is idempotent only when the optional idempotency key is present",
);
assert.equal(remember.inputSchema.properties.consolidate.type, "boolean");
assert.match(remember.inputSchema.properties.consolidate.description, /claims:write/);
const consolidate = tools.body.result.tools.find((tool: any) => tool.name === "titen_consolidate");
assert.equal(consolidate.inputSchema.properties.claims.type, "array");
assert.equal(consolidate.inputSchema.properties.claims.items.additionalProperties, false);
assert.deepEqual(consolidate.inputSchema.properties.claims.items.properties.kind.enum, [
"semantic_fact", "episodic_event", "preference", "procedural", "decision", "relationship",
]);
assert.equal(consolidate.annotations.idempotentHint, false);
const resolve = tools.body.result.tools.find((tool: any) => tool.name === "titen_project_resolve");
assert.equal(resolve.inputSchema.properties.create.type, "boolean");
assert.equal(resolve.annotations.readOnlyHint, false, "project resolution may create when authorized");
const checkpointGet = tools.body.result.tools.find(
(tool: any) => tool.name === "titen_checkpoint_get",
);
assert.equal(checkpointGet.annotations.readOnlyHint, true);
assert.equal(checkpointGet.annotations.idempotentHint, true);
});
test("the HTTP transport rejects unsafe origins and unsupported revisions", async () => {
const crossOrigin = await rpc(
{ jsonrpc: "2.0", id: 1, method: "ping" },
{ origin: "https://attacker.example" },
);
assert.equal(crossOrigin.status, 403);
const sameOrigin = await rpc(
{ jsonrpc: "2.0", id: 2, method: "ping" },
{ origin: running.url },
);
assert.equal(sameOrigin.status, 200);
const unsupported = await rpc(
{ jsonrpc: "2.0", id: 3, method: "ping" },
{ "mcp-protocol-version": "1999-01-01" },
);
assert.equal(unsupported.status, 400);
assert.equal(unsupported.body.error.code, -32600);
const noStream = await fetch(`${running.url}/mcp`, {
headers: {
accept: "text/event-stream",
authorization: `Bearer ${key}`,
},
});
assert.equal(noStream.status, 405);
});
test("a TLS proxy origin is trusted only through explicit configuration", async () => {
const dbPath = join(directory, "external-origin.db");
const externalOrigin = "https://titen.example.com";
const proxied = await serve({
dbPath,
port: 0,
hostname: "127.0.0.1",
quiet: true,
revision: "mcp-proxy",
mcpOrigin: externalOrigin,
});
const database = openDatabase(dbPath);
const provisioned = await provisionWith(createSqliteDb(database), { scopes: ["*"] });
database.close();
const call = (origin: string) =>
fetch(`${proxied.url}/mcp`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${provisioned.key}`,
origin,
},
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ping" }),
});
try {
assert.equal((await call(externalOrigin)).status, 200);
assert.equal((await call(proxied.url)).status, 403, "the internal HTTP origin is no longer trusted");
assert.equal((await call("https://attacker.example")).status, 403);
} finally {
await proxied.stop();
}
assert.equal(
(
await rpc(
{ jsonrpc: "2.0", id: 11, method: "ping" },
{ origin: externalOrigin, "x-forwarded-proto": "https" },
)
).status,
403,
"forwarded protocol does not make an unconfigured external origin trusted",
);
});
test("invalid configured MCP origins fail before the server starts", async () => {
for (const [index, mcpOrigin] of [
"ftp://titen.example.com",
"https://[email protected]",
"https://titen.example.com/path",
"https://titen.example.com/",
].entries()) {
await assert.rejects(
serve({
dbPath: join(directory, `invalid-origin-${index}.db`),
port: 0,
hostname: "127.0.0.1",
quiet: true,
mcpOrigin,
}),
/TITEN_MCP_ORIGIN must be an exact HTTP\(S\) origin/,
);
}
});
test("a tool call returns MCP content and a failure stays readable", async () => {
const called = await rpc({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "titen_remember",
arguments: {
subject_id: "user_mcp_compliance",
kind: "tool_result",
content: "Evidence appended through the MCP transport.",
source_type: "tool",
source_ref: "mcp#1",
trust: "verified",
},
},
});
assert.equal(called.body.jsonrpc, "2.0");
const content = called.body.result.content;
assert.ok(Array.isArray(content) && content[0].type === "text");
const remembered = JSON.parse(content[0].text);
assert.match(remembered.data.observation_id, /^obs_/);
assert.equal(remembered.meta.replayed, false);
assert.notEqual(called.body.result.isError, true);
const checkpoint = await rpc({
jsonrpc: "2.0",
id: 3,
method: "tools/call",
params: {
name: "titen_checkpoint_save",
arguments: {
subject_id: "user_mcp_compliance",
kind: "task_state",
state: { step: 1 },
ttl_seconds: 600,
},
},
});
const checkpointPayload = JSON.parse(checkpoint.body.result.content[0].text);
assert.match(checkpointPayload.data.checkpoint_id, /^ckpt_/);
assert.deepEqual(
Object.keys(checkpointPayload),
["data"],
"every successful tool result keeps the same data envelope",
);
// A bad argument is a readable tool result, not a transport error: the model
// has to be able to see what it got wrong.
const failed = await rpc({
jsonrpc: "2.0",
id: 2,
method: "tools/call",
params: { name: "titen_remember", arguments: { subject_id: "only-this" } },
});
assert.equal(failed.body.jsonrpc, "2.0");
assert.equal(failed.body.result.isError, true, "a tool failure is flagged in the result");
assert.ok(failed.body.result.content[0].text.length > 0, "the reason must be readable");
assert.equal(failed.body.error, undefined, "a tool failure is not a JSON-RPC error");
const missingProject = await rpc({
jsonrpc: "2.0",
id: 4,
method: "tools/call",
params: {
name: "titen_project_resolve",
arguments: { reference: "Rama/MCP-Missing" },
},
});
const missingPayload = JSON.parse(missingProject.body.result.content[0].text);
assert.equal(missingProject.body.result.isError, true);
assert.equal(missingPayload.code, "NOT_FOUND");
assert.equal(missingPayload.meta.request_id, missingProject.requestId);
assert.equal(missingPayload.meta.reason, "project_not_registered");
assert.equal(missingPayload.meta.reference, "rama/mcp-missing");
assert.equal(missingPayload.meta.can_create, true);
assert.equal(missingPayload.meta.support.classification, "expected");
assert.match(missingPayload.meta.support.action, /create=true/);
});
test("protocol-level errors use JSON-RPC error codes", async () => {
// `resources/list` used to stand in for "unknown method" here; it is served
// now, so this needs a method that genuinely is not implemented.
const unknownMethod = await rpc({ jsonrpc: "2.0", id: 1, method: "completion/complete" });
assert.equal(unknownMethod.body.error.code, -32601, "unknown method is -32601");
const served = await rpc({ jsonrpc: "2.0", id: 1, method: "resources/list" });
assert.equal(served.body.error, undefined, "resources/list is implemented, not unknown");
const unknownTool = await rpc({
jsonrpc: "2.0",
id: 2,
method: "tools/call",
params: { name: "titen_not_a_tool", arguments: {} },
});
assert.equal(unknownTool.body.error.code, -32602, "unknown tool is -32602");
const badVersion = await rpc({ id: 3, method: "ping" });
assert.equal(badVersion.body.error.code, -32600, "a missing jsonrpc field is -32600");
const malformed = await fetch(`${running.url}/mcp`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
body: "{not json",
});
const parseError = (await malformed.json()) as any;
assert.equal(parseError.error.code, -32700, "unparseable input is -32700");
const emptyBatch = await rpc([]);
assert.equal(emptyBatch.body.error.code, -32600, "an empty batch is one invalid request");
});
test("null ids are answered and missing ids are notifications", async () => {
const nullId = await rpc({ jsonrpc: "2.0", id: null, method: "ping" });
assert.equal(nullId.status, 200);
assert.equal(nullId.body.id, null, "an explicit null id is echoed in the response");
assert.deepEqual(nullId.body.result, {});
const notification = await rpc({ jsonrpc: "2.0", method: "tools/list" });
assert.equal(notification.status, 202);
assert.equal(notification.text, "", "tools/list without an id must not be answered");
});
test("a batch answers only the requests in it", async () => {
const res = await rpc([
{ jsonrpc: "2.0", id: 1, method: "ping" },
{ jsonrpc: "2.0", method: "notifications/initialized" },
{ jsonrpc: "2.0", id: 2, method: "tools/list" },
]);
assert.ok(Array.isArray(res.body), "a batch request returns an array");
assert.equal(res.body.length, 2, "the notification contributes no response");
assert.deepEqual(
res.body.map((entry: any) => entry.id).sort(),
[1, 2],
"each request is answered by id",
);
});
test("the transport is authenticated", async () => {
const anonymous = await fetch(`${running.url}/mcp`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
});
assert.equal(anonymous.status, 401, "MCP must not be reachable without a credential");
});