mirror of
https://github.com/rohitg00/agentmemory.git
synced 2026-09-14 20:16:33 +08:00
a0da02b6b3
* feat: add Copilot CLI plugin asset slice - plugin/.plugin/plugin.json: Copilot manifest with name/version/skills/mcpServers/hooks refs - plugin/.mcp.copilot.json: MCP server config with type:local, npx, env passthrough, tools:[*] - plugin/hooks/hooks.copilot.json: Copilot hooks (version:1) with 11 supported events and PreToolUse matcher - test/copilot-plugin.test.ts: 11 tests covering manifest, MCP config, and hooks validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Copilot CLI connect support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add GitHub Copilot CLI support Adds Copilot CLI support through a root plugin manifest, Copilot-specific MCP and hook configuration, and a connect adapter for MCP-only setup. Includes Windows-safe Copilot MCP command generation, COPILOT_HOME handling, Copilot hook payload normalization, generated hook scripts, and targeted tests for plugin shape, hook execution, and connect behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Harden Copilot hook handling Addresses upstream AI review suggestions by aligning the Copilot preToolUse matcher with the hook allowlist, narrowing hook payload fields at runtime, normalizing subagent fallbacks, and tightening hook config validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Copilot to first-run onboarding Includes GitHub Copilot CLI in the first-run agent picker and adds a regression test so the Copilot setup path remains discoverable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Default onboarding to Copilot inside Copilot CLI Detect Copilot CLI environment markers during first-run setup so pressing Enter wires the current agent instead of the historical Claude Code default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Support framed stdio MCP transport Accept Content-Length framed JSON-RPC messages in addition to the existing newline-delimited transport so Copilot CLI can initialize the standalone MCP server. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Narrow Copilot pre-tool session ids Ensures pre-tool-use only forwards string session IDs and falls back to unknown for invalid Copilot payload values, with regression coverage for the generated plugin script. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Ross Story <rostory@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Rohit Ghumare <ghumare64@gmail.com>
276 lines
8.3 KiB
TypeScript
276 lines
8.3 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import {
|
|
createMessageParser,
|
|
formatResponse,
|
|
processLine,
|
|
type JsonRpcResponse,
|
|
type RequestHandler,
|
|
} from "../src/mcp/transport.js";
|
|
|
|
function collector() {
|
|
const out: JsonRpcResponse[] = [];
|
|
const err: string[] = [];
|
|
return {
|
|
out,
|
|
err,
|
|
writeOut: (r: JsonRpcResponse) => out.push(r),
|
|
writeErr: (m: string) => err.push(m),
|
|
};
|
|
}
|
|
|
|
const okHandler: RequestHandler = async (method) => ({ method });
|
|
|
|
describe("processLine — request path", () => {
|
|
it("emits a response for a request with id", async () => {
|
|
const c = collector();
|
|
await processLine(
|
|
JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize" }),
|
|
okHandler,
|
|
c.writeOut,
|
|
c.writeErr,
|
|
);
|
|
expect(c.out).toHaveLength(1);
|
|
expect(c.out[0]).toEqual({
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
result: { method: "initialize" },
|
|
});
|
|
});
|
|
|
|
it("emits an error response when the handler throws on a request", async () => {
|
|
const c = collector();
|
|
const throwingHandler: RequestHandler = async () => {
|
|
throw new Error("boom");
|
|
};
|
|
await processLine(
|
|
JSON.stringify({ jsonrpc: "2.0", id: 7, method: "tools/list" }),
|
|
throwingHandler,
|
|
c.writeOut,
|
|
c.writeErr,
|
|
);
|
|
expect(c.out).toHaveLength(1);
|
|
expect(c.out[0].id).toBe(7);
|
|
expect(c.out[0].error?.code).toBe(-32603);
|
|
expect(c.out[0].error?.message).toBe("boom");
|
|
});
|
|
});
|
|
|
|
describe("processLine — notification path (#129)", () => {
|
|
it("does NOT emit a response for a notification (no id field)", async () => {
|
|
const c = collector();
|
|
const handlerCalled = vi.fn(async () => ({ shouldNotEscape: true }));
|
|
await processLine(
|
|
JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
method: "notifications/initialized",
|
|
}),
|
|
handlerCalled,
|
|
c.writeOut,
|
|
c.writeErr,
|
|
);
|
|
expect(handlerCalled).toHaveBeenCalledOnce();
|
|
expect(c.out).toHaveLength(0);
|
|
expect(c.err).toHaveLength(0);
|
|
});
|
|
|
|
it("does NOT emit a response for a notification with id: null", async () => {
|
|
const c = collector();
|
|
await processLine(
|
|
JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: null,
|
|
method: "notifications/cancelled",
|
|
}),
|
|
okHandler,
|
|
c.writeOut,
|
|
c.writeErr,
|
|
);
|
|
expect(c.out).toHaveLength(0);
|
|
});
|
|
|
|
it("logs to stderr but does NOT emit a response when a notification handler throws", async () => {
|
|
const c = collector();
|
|
const throwingHandler: RequestHandler = async () => {
|
|
throw new Error("notification crash");
|
|
};
|
|
await processLine(
|
|
JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
method: "notifications/initialized",
|
|
}),
|
|
throwingHandler,
|
|
c.writeOut,
|
|
c.writeErr,
|
|
);
|
|
expect(c.out).toHaveLength(0);
|
|
expect(c.err).toHaveLength(1);
|
|
expect(c.err[0]).toContain("notification handler error");
|
|
expect(c.err[0]).toContain("notification crash");
|
|
});
|
|
});
|
|
|
|
describe("processLine — malformed input", () => {
|
|
it("emits a parse error with id: null for invalid JSON", async () => {
|
|
const c = collector();
|
|
await processLine("not-json", okHandler, c.writeOut, c.writeErr);
|
|
expect(c.out).toHaveLength(1);
|
|
expect(c.out[0].id).toBeNull();
|
|
expect(c.out[0].error?.code).toBe(-32700);
|
|
expect(c.out[0].error?.message).toBe("Parse error");
|
|
});
|
|
|
|
it("ignores empty / whitespace-only lines", async () => {
|
|
const c = collector();
|
|
await processLine("", okHandler, c.writeOut, c.writeErr);
|
|
await processLine(" \t ", okHandler, c.writeOut, c.writeErr);
|
|
expect(c.out).toHaveLength(0);
|
|
expect(c.err).toHaveLength(0);
|
|
});
|
|
|
|
it("emits an Invalid Request error when a request has an id but no jsonrpc", async () => {
|
|
const c = collector();
|
|
await processLine(
|
|
JSON.stringify({ id: 1, method: "tools/list" }),
|
|
okHandler,
|
|
c.writeOut,
|
|
c.writeErr,
|
|
);
|
|
expect(c.out).toHaveLength(1);
|
|
expect(c.out[0].id).toBe(1);
|
|
expect(c.out[0].error?.code).toBe(-32600);
|
|
});
|
|
|
|
it("silently drops a malformed message that has no id (treated as notification)", async () => {
|
|
const c = collector();
|
|
await processLine(
|
|
JSON.stringify({ method: "broken" }),
|
|
okHandler,
|
|
c.writeOut,
|
|
c.writeErr,
|
|
);
|
|
// No jsonrpc field, no id — drop without responding.
|
|
expect(c.out).toHaveLength(0);
|
|
});
|
|
|
|
it("silently drops a malformed message with a non-primitive id (can't safely echo)", async () => {
|
|
const c = collector();
|
|
await processLine(
|
|
JSON.stringify({ id: { nested: true }, method: "broken" }),
|
|
okHandler,
|
|
c.writeOut,
|
|
c.writeErr,
|
|
);
|
|
// Malformed shape + non-primitive id — can't echo id back, drop silently.
|
|
expect(c.out).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("processLine — id type validation (JSON-RPC §4)", () => {
|
|
it("rejects a request whose id is an object with -32600 and id: null", async () => {
|
|
const c = collector();
|
|
const handlerCalled = vi.fn(okHandler);
|
|
await processLine(
|
|
JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: { bogus: true },
|
|
method: "tools/list",
|
|
}),
|
|
handlerCalled,
|
|
c.writeOut,
|
|
c.writeErr,
|
|
);
|
|
expect(handlerCalled).not.toHaveBeenCalled();
|
|
expect(c.out).toHaveLength(1);
|
|
expect(c.out[0].id).toBeNull();
|
|
expect(c.out[0].error?.code).toBe(-32600);
|
|
expect(c.out[0].error?.message).toContain("id must be");
|
|
});
|
|
|
|
it("rejects a request whose id is an array", async () => {
|
|
const c = collector();
|
|
const handlerCalled = vi.fn(okHandler);
|
|
await processLine(
|
|
JSON.stringify({ jsonrpc: "2.0", id: [1, 2], method: "tools/list" }),
|
|
handlerCalled,
|
|
c.writeOut,
|
|
c.writeErr,
|
|
);
|
|
expect(handlerCalled).not.toHaveBeenCalled();
|
|
expect(c.out).toHaveLength(1);
|
|
expect(c.out[0].id).toBeNull();
|
|
expect(c.out[0].error?.code).toBe(-32600);
|
|
});
|
|
|
|
it("rejects a request whose id is a boolean", async () => {
|
|
const c = collector();
|
|
const handlerCalled = vi.fn(okHandler);
|
|
await processLine(
|
|
JSON.stringify({ jsonrpc: "2.0", id: true, method: "tools/list" }),
|
|
handlerCalled,
|
|
c.writeOut,
|
|
c.writeErr,
|
|
);
|
|
expect(handlerCalled).not.toHaveBeenCalled();
|
|
expect(c.out).toHaveLength(1);
|
|
expect(c.out[0].id).toBeNull();
|
|
expect(c.out[0].error?.code).toBe(-32600);
|
|
});
|
|
|
|
it("accepts a request with string id", async () => {
|
|
const c = collector();
|
|
await processLine(
|
|
JSON.stringify({ jsonrpc: "2.0", id: "abc-123", method: "ping" }),
|
|
okHandler,
|
|
c.writeOut,
|
|
c.writeErr,
|
|
);
|
|
expect(c.out).toHaveLength(1);
|
|
expect(c.out[0].id).toBe("abc-123");
|
|
expect(c.out[0].result).toEqual({ method: "ping" });
|
|
});
|
|
});
|
|
|
|
describe("stdio framing", () => {
|
|
it("parses Content-Length framed MCP messages split across chunks", () => {
|
|
const messages: string[] = [];
|
|
const parser = createMessageParser((message) => messages.push(message));
|
|
const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize" });
|
|
const framed = `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`;
|
|
|
|
parser.push(framed.slice(0, 12));
|
|
parser.push(framed.slice(12));
|
|
|
|
expect(messages).toEqual([body]);
|
|
expect(parser.isFramed()).toBe(true);
|
|
});
|
|
|
|
it("parses newline-delimited JSON for existing clients", () => {
|
|
const messages: string[] = [];
|
|
const parser = createMessageParser((message) => messages.push(message));
|
|
const first = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" });
|
|
const second = JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
|
|
parser.push(`${first}\n${second}\n`);
|
|
|
|
expect(messages).toEqual([first, second]);
|
|
expect(parser.isFramed()).toBe(false);
|
|
});
|
|
|
|
it("formats responses with Content-Length framing when requested", () => {
|
|
const response: JsonRpcResponse = {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
result: { ok: true },
|
|
};
|
|
const formatted = formatResponse(response, true);
|
|
|
|
expect(Array.isArray(formatted)).toBe(true);
|
|
if (!Array.isArray(formatted)) throw new Error("expected framed response");
|
|
const header = formatted[0].toString("ascii");
|
|
const body = formatted[1].toString("utf8");
|
|
|
|
expect(header).toBe(`Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n`);
|
|
expect(JSON.parse(body)).toEqual(response);
|
|
});
|
|
});
|