kojee-mcp 0.2.2 → 0.4.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.
@@ -1,3 +1,8 @@
1
+ import {
2
+ deriveDiscoveryKey,
3
+ findClaudeAncestorPid
4
+ } from "./chunk-36DMIXH7.js";
5
+
1
6
  // src/index.ts
2
7
  import fs2 from "fs";
3
8
  import path2 from "path";
@@ -278,9 +283,41 @@ function translateHttpError(status, errorCode, _trigger) {
278
283
  }
279
284
  return null;
280
285
  }
286
+ var TANDEM_ERROR_MESSAGES = {
287
+ [-32003]: () => "This Tandem is hardened to owner-only membership; you can't join.",
288
+ [-32004]: () => "You aren't a member of that Tandem. Use tandem_join(join_link) first.",
289
+ [-32006]: (data) => {
290
+ const retry = data?.["retry_after_seconds"] ?? "a moment";
291
+ return `Rate limit hit on this Tandem. Retry after ${retry} seconds.`;
292
+ },
293
+ [-32007]: (data) => {
294
+ const rule = data?.["rule"] ?? "policy";
295
+ return `Message rejected by Tandem policy (${rule}).`;
296
+ },
297
+ [-32011]: (data) => {
298
+ const id = data?.["tandem_id"] ?? "unknown";
299
+ return `Tandem ${id} doesn't exist or isn't visible to you.`;
300
+ },
301
+ [-32015]: (data) => {
302
+ const candidates = data?.["candidates"] ?? [];
303
+ return `An @-mention matched multiple members and is ambiguous. Retry with explicit mentions[]. Candidates: ${candidates.join(", ")}.`;
304
+ }
305
+ };
281
306
  function translateJsonRpcError(error) {
282
307
  const msg = error.message ?? "";
283
308
  const msgLower = msg.toLowerCase();
309
+ const tandemMessage = TANDEM_ERROR_MESSAGES[error.code];
310
+ if (tandemMessage) {
311
+ return {
312
+ content: [
313
+ {
314
+ type: "text",
315
+ text: tandemMessage(error.data)
316
+ }
317
+ ],
318
+ isError: true
319
+ };
320
+ }
284
321
  switch (error.code) {
285
322
  case -32601:
286
323
  return makeError(
@@ -347,6 +384,10 @@ function makeError(text) {
347
384
  };
348
385
  }
349
386
 
387
+ // src/tandem/session-id.ts
388
+ import { ulid } from "ulidx";
389
+ var MCP_SESSION_ID = ulid();
390
+
350
391
  // src/gateway-client.ts
351
392
  var STEP_UP_POLL_INTERVAL_MS = 5e3;
352
393
  var STEP_UP_MAX_TIMEOUT_MS = 3e5;
@@ -365,6 +406,21 @@ var GatewayClient = class {
365
406
  currentNonce;
366
407
  requestCounter = 0;
367
408
  endpoint;
409
+ /**
410
+ * Expose the DPoP signing key so peer modules sharing auth state
411
+ * (e.g. tandem/event-stream.ts) can sign their own requests.
412
+ */
413
+ getPrivateKey() {
414
+ return this.privateKey;
415
+ }
416
+ /**
417
+ * Expose the bot_key_id (kid) for DPoP proof headers. Paired with
418
+ * getPrivateKey() so peer modules can construct proofs without
419
+ * threading the key material through their own constructors.
420
+ */
421
+ getKid() {
422
+ return this.kid;
423
+ }
368
424
  /**
369
425
  * Derive a deterministic session ID from the gateway token.
370
426
  * session_id = sha256(token + "proxy").slice(0, 16)
@@ -447,7 +503,8 @@ var GatewayClient = class {
447
503
  headers: {
448
504
  "Content-Type": "application/json",
449
505
  Authorization: `DPoP ${this.token}`,
450
- DPoP: proof
506
+ DPoP: proof,
507
+ "Mcp-Session-Id": MCP_SESSION_ID
451
508
  },
452
509
  body: JSON.stringify(rpcRequest)
453
510
  });
@@ -591,16 +648,28 @@ import {
591
648
  ListToolsRequestSchema,
592
649
  CallToolRequestSchema
593
650
  } from "@modelcontextprotocol/sdk/types.js";
594
- function createMcpServer(registry) {
651
+ function buildChannelInstructions(tandemMembershipCount, eventLogPath) {
652
+ const intro = 'Tandem events are delivered to you in three ways depending on which is active in your Claude Code session:\n\n(1) If channel notifications are available, you\'ll see them as `<channel source="kojee-mcp" ...>` tags in your context. Each tag carries tandem_id, message_id, from_principal, kind, severity. Reply with tandem_send(tandem_id, text, reply_to=<message_id>); read more context with tandem_messages(tandem_id, since=cursor).\n\n';
653
+ const monitorSection = `(2) For sustained wake-from-idle WITHOUT channel notifications, the proxy writes one event line per Tandem message to ${eventLogPath}. At the start of every session, spawn ONE Monitor task: Monitor(command="tail -n +1 -F ${eventLogPath}", persistent=true, description="kojee Tandem events"). Each appended line will arrive as a separate wake notification. Use the cursor in the line to call tandem_messages(tandem_id, since=cursor-1) for the full message body.
654
+
655
+ `;
656
+ const listenSection = "(3) If you want to BLOCK until any single reply lands (rather than receive a stream of events), call tandem_listen(tandem_id, since=cursor, timeout_ms=N) instead.";
657
+ const advice = "\n\nPrefer (2) at session start \u2014 it's the default no-allowlist wake mechanism. (1) supplements it when channels are enabled; (3) is for one-shot blocking waits.";
658
+ if (tandemMembershipCount === 0) {
659
+ return intro + listenSection;
660
+ }
661
+ return intro + monitorSection + listenSection + advice;
662
+ }
663
+ function createMcpServer(registry, adapter, tandemMembershipCount = -1, eventLogPath) {
664
+ const capabilities = { tools: {} };
665
+ if (adapter.supportsChannels) {
666
+ capabilities.experimental = { "claude/channel": {} };
667
+ }
595
668
  const server = new Server(
669
+ { name: "kojee-mcp", version: "0.3.0" },
596
670
  {
597
- name: "kojee-mcp",
598
- version: "0.2.0"
599
- },
600
- {
601
- capabilities: {
602
- tools: {}
603
- }
671
+ capabilities,
672
+ ...adapter.supportsChannels ? { instructions: buildChannelInstructions(tandemMembershipCount, eventLogPath ?? "") } : {}
604
673
  }
605
674
  );
606
675
  server.setRequestHandler(ListToolsRequestSchema, async () => {
@@ -615,10 +684,7 @@ function createMcpServer(registry) {
615
684
  const { name, arguments: args } = request.params;
616
685
  const rawResult = await registry.callTool(name, args ?? {});
617
686
  const result = translateToolCallResult(rawResult);
618
- return {
619
- content: result.content,
620
- isError: result.isError
621
- };
687
+ return { content: result.content, isError: result.isError };
622
688
  });
623
689
  return server;
624
690
  }
@@ -628,6 +694,235 @@ async function startMcpServer(server) {
628
694
  console.error("[mcp] Server started on stdio transport");
629
695
  }
630
696
 
697
+ // src/runtime/detect.ts
698
+ import childProcess from "child_process";
699
+ function detectRuntime(env = process.env) {
700
+ if (env["KOJEE_RUNTIME"] === "claude-code") return "claude-code";
701
+ if (env["CLAUDE_CODE_SESSION_ID"]) return "claude-code";
702
+ if (parentProcessLooksLikeClaude()) return "claude-code";
703
+ return "unknown";
704
+ }
705
+ function parentProcessLooksLikeClaude() {
706
+ if (process.platform === "win32") return false;
707
+ try {
708
+ let pid = process.ppid;
709
+ for (let depth = 0; depth < 5 && pid && pid > 1; depth++) {
710
+ const cmd = childProcess.execFileSync("ps", ["-p", String(pid), "-o", "command="], {
711
+ encoding: "utf8",
712
+ stdio: ["ignore", "pipe", "ignore"]
713
+ }).trim();
714
+ if (/(^|\/)claude(\.app)?(\/|$|\s|\\)/i.test(cmd) || /Claude Helper/i.test(cmd)) {
715
+ return true;
716
+ }
717
+ const ppidOut = childProcess.execFileSync("ps", ["-o", "ppid=", "-p", String(pid)], {
718
+ encoding: "utf8",
719
+ stdio: ["ignore", "pipe", "ignore"]
720
+ }).trim();
721
+ pid = Number.parseInt(ppidOut, 10);
722
+ if (!Number.isFinite(pid)) return false;
723
+ }
724
+ } catch {
725
+ }
726
+ return false;
727
+ }
728
+
729
+ // src/adapters/claude-code.ts
730
+ function computeSeverity(event) {
731
+ if (event.type === "state_change") return "high";
732
+ if (event.mentions && event.mentions.length > 0) {
733
+ return "high";
734
+ }
735
+ return "normal";
736
+ }
737
+ function formatBody(event) {
738
+ if (event.type === "state_change") {
739
+ return `[Tandem: ${event.tandem_id}] ${event.from.displayname}: ${event.content.body}`;
740
+ }
741
+ return [
742
+ `[Tandem: ${event.tandem_id}] ${event.from.displayname} (${event.from.principal}) \u2014 ${event.kind}:`,
743
+ event.content.body,
744
+ "",
745
+ `> reply with tandem_send(tandem_id="${event.tandem_id}", text="...", reply_to="${event.id}")`
746
+ ].join("\n");
747
+ }
748
+ var claudeCodeAdapter = {
749
+ runtime: "claude-code",
750
+ supportsChannels: true,
751
+ formatTandemEvent(event) {
752
+ const meta = {
753
+ tandem_id: event.tandem_id,
754
+ message_id: event.id,
755
+ cursor: String(event.cursor),
756
+ kind: event.kind,
757
+ from_principal: event.from.principal,
758
+ from_display: event.from.displayname,
759
+ severity: computeSeverity(event)
760
+ };
761
+ return { content: formatBody(event), meta };
762
+ }
763
+ };
764
+
765
+ // src/adapters/unknown.ts
766
+ var unknownAdapter = {
767
+ runtime: "unknown",
768
+ supportsChannels: false,
769
+ formatTandemEvent() {
770
+ throw new Error(
771
+ "unknownAdapter.formatTandemEvent() is unreachable \u2014 caller should gate on supportsChannels"
772
+ );
773
+ }
774
+ };
775
+
776
+ // src/tandem/event-stream.ts
777
+ async function startEventStream(opts) {
778
+ let stopped = false;
779
+ let lastCursor = null;
780
+ const controller = new AbortController();
781
+ void (async function loop() {
782
+ let backoffMs = 1e3;
783
+ while (!stopped) {
784
+ try {
785
+ await connectAndConsume(opts, lastCursor, controller, (cursor) => {
786
+ lastCursor = cursor;
787
+ backoffMs = 1e3;
788
+ });
789
+ } catch (err) {
790
+ if (stopped) return;
791
+ console.error("[event-stream] disconnect:", err.message);
792
+ }
793
+ if (stopped) return;
794
+ const jitter = Math.random() * backoffMs;
795
+ await sleep2(jitter);
796
+ backoffMs = Math.min(backoffMs * 2, 3e4);
797
+ }
798
+ })();
799
+ return () => {
800
+ stopped = true;
801
+ controller.abort();
802
+ };
803
+ }
804
+ async function connectAndConsume(opts, sinceCursor, controller, onCursor) {
805
+ const params = new URLSearchParams();
806
+ if (sinceCursor !== null) params.set("since", String(sinceCursor));
807
+ const url = `${opts.brokerUrl}/api/v2/tandems/stream${params.toString() ? "?" + params.toString() : ""}`;
808
+ const proof = await createDPoPProof(
809
+ opts.gateway.getPrivateKey(),
810
+ opts.gateway.getKid(),
811
+ "GET",
812
+ url,
813
+ void 0,
814
+ opts.token
815
+ );
816
+ const res = await fetch(url, {
817
+ method: "GET",
818
+ headers: {
819
+ Authorization: `DPoP ${opts.token}`,
820
+ DPoP: proof,
821
+ "Mcp-Session-Id": MCP_SESSION_ID,
822
+ Accept: "text/event-stream"
823
+ },
824
+ signal: controller.signal
825
+ });
826
+ if (!res.ok) throw new Error(`SSE connect failed: ${res.status}`);
827
+ if (!res.body) throw new Error("SSE response has no body");
828
+ await consumeSse(res.body, opts, onCursor);
829
+ }
830
+ async function consumeSse(body, opts, onCursor) {
831
+ const reader = body.getReader();
832
+ const decoder = new TextDecoder();
833
+ let buffer = "";
834
+ while (true) {
835
+ const { value, done } = await reader.read();
836
+ if (done) return;
837
+ buffer += decoder.decode(value, { stream: true });
838
+ const events = drainSseEvents(buffer);
839
+ buffer = events.remaining;
840
+ for (const evt of events.events) {
841
+ if (evt.event === "stream_revoked") {
842
+ throw new Error("stream_revoked \u2014 reconnect needed");
843
+ }
844
+ if (evt.event === "heartbeat") continue;
845
+ if (evt.event === "message" || evt.event === "state_change") {
846
+ try {
847
+ const raw = JSON.parse(evt.data);
848
+ const parsed = normalizeBackendEvent(raw, evt.event);
849
+ onCursor(parsed.cursor);
850
+ opts.queue?.push(parsed);
851
+ const channel = opts.adapter.formatTandemEvent(parsed);
852
+ await opts.server.notification({
853
+ method: "notifications/claude/channel",
854
+ params: channel
855
+ });
856
+ opts.queue?.markChannelDelivered(parsed.id);
857
+ if (opts.eventLog) {
858
+ try {
859
+ await opts.eventLog.append(parsed);
860
+ opts.queue?.markMonitorDelivered(parsed.id);
861
+ } catch (err) {
862
+ console.error("[event-stream] event-log append failed:", err);
863
+ }
864
+ }
865
+ } catch (err) {
866
+ console.error("[event-stream] failed to handle event:", err);
867
+ }
868
+ }
869
+ }
870
+ }
871
+ }
872
+ function drainSseEvents(input) {
873
+ const events = [];
874
+ const parts = input.split("\n\n");
875
+ const remaining = parts.pop() ?? "";
876
+ for (const block of parts) {
877
+ let id;
878
+ let event = "message";
879
+ const dataLines = [];
880
+ for (const line of block.split("\n")) {
881
+ if (line.startsWith("id: ")) id = line.slice(4);
882
+ else if (line.startsWith("event: ")) event = line.slice(7);
883
+ else if (line.startsWith("data: ")) dataLines.push(line.slice(6));
884
+ }
885
+ if (dataLines.length > 0) events.push({ id, event, data: dataLines.join("\n") });
886
+ }
887
+ return { events, remaining };
888
+ }
889
+ function sleep2(ms) {
890
+ return new Promise((r) => setTimeout(r, ms));
891
+ }
892
+ function normalizeBackendEvent(raw, sseEventType) {
893
+ const obj = raw ?? {};
894
+ const maybeFrom = obj["from"];
895
+ if (maybeFrom && typeof maybeFrom["principal"] === "string") {
896
+ return raw;
897
+ }
898
+ const sender = obj["sender"] ?? {};
899
+ const principal = sender["principal_id"] ?? "";
900
+ const agentId = sender["agent_id"];
901
+ const displayname = principal ? `principal:${principal.slice(0, 8)}` : "unknown";
902
+ const type = sseEventType === "state_change" ? "state_change" : "message";
903
+ const kind = obj["kind"] ?? "message";
904
+ return {
905
+ type,
906
+ id: obj["message_id"] ?? obj["id"] ?? "",
907
+ tandem_id: obj["tandem_id"] ?? "",
908
+ cursor: obj["cursor"] ?? 0,
909
+ time: obj["time"] ?? (/* @__PURE__ */ new Date()).toISOString(),
910
+ from: {
911
+ member_id: "",
912
+ principal,
913
+ ...agentId ? { agent_id: agentId } : {},
914
+ displayname
915
+ },
916
+ kind,
917
+ content: {
918
+ body: obj["body"] ?? "",
919
+ ...typeof obj["format"] === "string" ? { format: obj["format"] } : {}
920
+ },
921
+ ...Array.isArray(obj["mentions"]) ? { mentions: obj["mentions"] } : {},
922
+ ...obj["reply_to"] !== void 0 ? { reply_to: obj["reply_to"] } : {}
923
+ };
924
+ }
925
+
631
926
  // src/index.ts
632
927
  var DEFAULT_KEYSTORE_PATH = path2.join(
633
928
  process.env["HOME"] ?? "~",
@@ -640,18 +935,109 @@ function isDPoPEnrollmentError(err) {
640
935
  if (msg.includes("generate a new")) return false;
641
936
  return msg.includes("401") || msg.includes("authentication failed") || msg.includes("dpop") || msg.includes("key enrollment") || msg.includes("kid");
642
937
  }
938
+ function selectAdapter() {
939
+ const runtime = detectRuntime();
940
+ if (runtime === "claude-code") return claudeCodeAdapter;
941
+ return unknownAdapter;
942
+ }
643
943
  async function startProxy(config) {
644
944
  const keystorePath = config.keystorePath || DEFAULT_KEYSTORE_PATH;
645
- console.error(`[kojee-mcp] Starting proxy for ${config.url}`);
646
- const registry = await enrollAndDiscover(config, keystorePath);
945
+ const adapter = selectAdapter();
946
+ console.error(`[kojee-mcp] Starting proxy for ${config.url} (runtime=${adapter.runtime})`);
947
+ const { registry, gateway } = await enrollAndDiscover(config, keystorePath);
647
948
  console.error(
648
949
  `[kojee-mcp] Ready \u2014 ${registry.toolCount} tools available from ${config.url}`
649
950
  );
650
- const server = createMcpServer(registry);
651
- process.stdin.on("end", () => {
652
- console.error("[kojee-mcp] stdin closed, exiting");
653
- process.exit(0);
654
- });
951
+ let tandemMembershipCount = -1;
952
+ try {
953
+ const result = await gateway.sendRpc("tools/call", { name: "tandem_list", arguments: {} });
954
+ const maybeErr = result;
955
+ if (!maybeErr.isError) {
956
+ const text = maybeErr.content?.[0]?.text;
957
+ try {
958
+ const parsed = text ? JSON.parse(text) : {};
959
+ if (Array.isArray(parsed.tandems)) {
960
+ tandemMembershipCount = parsed.tandems.length;
961
+ } else if (Array.isArray(parsed)) {
962
+ tandemMembershipCount = parsed.length;
963
+ } else {
964
+ tandemMembershipCount = 0;
965
+ }
966
+ } catch {
967
+ }
968
+ }
969
+ } catch (err) {
970
+ console.error("[kojee-mcp] tandem_list probe failed:", err.message);
971
+ }
972
+ console.error(`[kojee-mcp] Tandem memberships: ${tandemMembershipCount === -1 ? "unknown" : tandemMembershipCount}`);
973
+ let server;
974
+ if (adapter.supportsChannels) {
975
+ const { EventQueue } = await import("./event-queue-5YVJFR3E.js");
976
+ const { startHookServer } = await import("./hook-server-43QS7L7P.js");
977
+ const {
978
+ writeDiscoveryByKey,
979
+ cleanupDiscoveryByKey,
980
+ sweepStaleDiscovery
981
+ } = await import("./session-discovery-WSHLR4OV.js");
982
+ const { startEventLog, sweepStaleEventLogs } = await import("./event-log-ETWR6PPY.js");
983
+ sweepStaleDiscovery();
984
+ sweepStaleEventLogs();
985
+ const ccPid = findClaudeAncestorPid();
986
+ const projectDir = process.env["CLAUDE_PROJECT_DIR"];
987
+ const discoveryKey = deriveDiscoveryKey(projectDir, ccPid);
988
+ const eventLog = startEventLog({ key: discoveryKey });
989
+ server = createMcpServer(registry, adapter, tandemMembershipCount, eventLog.path);
990
+ const queue = new EventQueue();
991
+ const hookServer = await startHookServer({ port: 0, queue, adapter });
992
+ writeDiscoveryByKey(discoveryKey, {
993
+ schema: 2,
994
+ discoveryKey,
995
+ ccPid,
996
+ projectDir: projectDir ?? null,
997
+ proxyPid: process.pid,
998
+ // Legacy `pid` mirrors `proxyPid` so the existing event-log sweep
999
+ // (which reads `data.pid`) still considers this entry live.
1000
+ pid: process.pid,
1001
+ port: hookServer.port,
1002
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1003
+ brokerUrl: config.url
1004
+ });
1005
+ const cleanupDiscoveryFile = () => cleanupDiscoveryByKey(discoveryKey);
1006
+ process.on("exit", () => cleanupDiscoveryFile());
1007
+ process.on("SIGINT", () => {
1008
+ cleanupDiscoveryFile();
1009
+ process.exit(0);
1010
+ });
1011
+ process.on("SIGTERM", () => {
1012
+ cleanupDiscoveryFile();
1013
+ process.exit(0);
1014
+ });
1015
+ process.on("exit", () => eventLog.cleanup());
1016
+ const cancelStream = await startEventStream({
1017
+ brokerUrl: config.url,
1018
+ token: config.token,
1019
+ gateway,
1020
+ adapter,
1021
+ server,
1022
+ queue,
1023
+ eventLog
1024
+ });
1025
+ process.stdin.on("end", () => {
1026
+ cancelStream();
1027
+ cleanupDiscoveryFile();
1028
+ eventLog.cleanup();
1029
+ hookServer.stop().finally(() => {
1030
+ console.error("[kojee-mcp] stdin closed, exiting");
1031
+ process.exit(0);
1032
+ });
1033
+ });
1034
+ } else {
1035
+ server = createMcpServer(registry, adapter, tandemMembershipCount);
1036
+ process.stdin.on("end", () => {
1037
+ console.error("[kojee-mcp] stdin closed, exiting");
1038
+ process.exit(0);
1039
+ });
1040
+ }
655
1041
  await startMcpServer(server);
656
1042
  }
657
1043
  async function enrollAndDiscover(config, keystorePath, isRetry = false) {
@@ -669,7 +1055,7 @@ async function enrollAndDiscover(config, keystorePath, isRetry = false) {
669
1055
  const registry = new ToolRegistry(gateway);
670
1056
  try {
671
1057
  await registry.discoverTools();
672
- return registry;
1058
+ return { registry, gateway };
673
1059
  } catch (err) {
674
1060
  if (isRetry || !isDPoPEnrollmentError(err)) {
675
1061
  throw err;
@@ -678,9 +1064,7 @@ async function enrollAndDiscover(config, keystorePath, isRetry = false) {
678
1064
  "[kojee-mcp] Auth failed, attempting recovery with fresh enrollment..."
679
1065
  );
680
1066
  try {
681
- if (fs2.existsSync(keystorePath)) {
682
- fs2.unlinkSync(keystorePath);
683
- }
1067
+ if (fs2.existsSync(keystorePath)) fs2.unlinkSync(keystorePath);
684
1068
  } catch (unlinkErr) {
685
1069
  console.error("[kojee-mcp] Could not remove stale keystore:", unlinkErr);
686
1070
  }
@@ -689,5 +1073,6 @@ async function enrollAndDiscover(config, keystorePath, isRetry = false) {
689
1073
  }
690
1074
 
691
1075
  export {
1076
+ AuthModule,
692
1077
  startProxy
693
1078
  };