kojee-mcp 0.2.1 → 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,4 +1,10 @@
1
+ import {
2
+ deriveDiscoveryKey,
3
+ findClaudeAncestorPid
4
+ } from "./chunk-36DMIXH7.js";
5
+
1
6
  // src/index.ts
7
+ import fs2 from "fs";
2
8
  import path2 from "path";
3
9
 
4
10
  // src/auth/auth-module.ts
@@ -277,9 +283,41 @@ function translateHttpError(status, errorCode, _trigger) {
277
283
  }
278
284
  return null;
279
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
+ };
280
306
  function translateJsonRpcError(error) {
281
307
  const msg = error.message ?? "";
282
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
+ }
283
321
  switch (error.code) {
284
322
  case -32601:
285
323
  return makeError(
@@ -346,6 +384,10 @@ function makeError(text) {
346
384
  };
347
385
  }
348
386
 
387
+ // src/tandem/session-id.ts
388
+ import { ulid } from "ulidx";
389
+ var MCP_SESSION_ID = ulid();
390
+
349
391
  // src/gateway-client.ts
350
392
  var STEP_UP_POLL_INTERVAL_MS = 5e3;
351
393
  var STEP_UP_MAX_TIMEOUT_MS = 3e5;
@@ -364,6 +406,21 @@ var GatewayClient = class {
364
406
  currentNonce;
365
407
  requestCounter = 0;
366
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
+ }
367
424
  /**
368
425
  * Derive a deterministic session ID from the gateway token.
369
426
  * session_id = sha256(token + "proxy").slice(0, 16)
@@ -446,7 +503,8 @@ var GatewayClient = class {
446
503
  headers: {
447
504
  "Content-Type": "application/json",
448
505
  Authorization: `DPoP ${this.token}`,
449
- DPoP: proof
506
+ DPoP: proof,
507
+ "Mcp-Session-Id": MCP_SESSION_ID
450
508
  },
451
509
  body: JSON.stringify(rpcRequest)
452
510
  });
@@ -590,16 +648,28 @@ import {
590
648
  ListToolsRequestSchema,
591
649
  CallToolRequestSchema
592
650
  } from "@modelcontextprotocol/sdk/types.js";
593
- 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
+ }
594
668
  const server = new Server(
669
+ { name: "kojee-mcp", version: "0.3.0" },
595
670
  {
596
- name: "kojee-mcp",
597
- version: "0.2.0"
598
- },
599
- {
600
- capabilities: {
601
- tools: {}
602
- }
671
+ capabilities,
672
+ ...adapter.supportsChannels ? { instructions: buildChannelInstructions(tandemMembershipCount, eventLogPath ?? "") } : {}
603
673
  }
604
674
  );
605
675
  server.setRequestHandler(ListToolsRequestSchema, async () => {
@@ -614,10 +684,7 @@ function createMcpServer(registry) {
614
684
  const { name, arguments: args } = request.params;
615
685
  const rawResult = await registry.callTool(name, args ?? {});
616
686
  const result = translateToolCallResult(rawResult);
617
- return {
618
- content: result.content,
619
- isError: result.isError
620
- };
687
+ return { content: result.content, isError: result.isError };
621
688
  });
622
689
  return server;
623
690
  }
@@ -627,15 +694,353 @@ async function startMcpServer(server) {
627
694
  console.error("[mcp] Server started on stdio transport");
628
695
  }
629
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
+
630
926
  // src/index.ts
631
927
  var DEFAULT_KEYSTORE_PATH = path2.join(
632
928
  process.env["HOME"] ?? "~",
633
929
  ".kojee",
634
930
  "keypair.json"
635
931
  );
932
+ function isDPoPEnrollmentError(err) {
933
+ const msg = String(err?.message ?? err ?? "").toLowerCase();
934
+ if (msg.includes("invalid or expired") && msg.includes("token")) return false;
935
+ if (msg.includes("generate a new")) return false;
936
+ return msg.includes("401") || msg.includes("authentication failed") || msg.includes("dpop") || msg.includes("key enrollment") || msg.includes("kid");
937
+ }
938
+ function selectAdapter() {
939
+ const runtime = detectRuntime();
940
+ if (runtime === "claude-code") return claudeCodeAdapter;
941
+ return unknownAdapter;
942
+ }
636
943
  async function startProxy(config) {
637
944
  const keystorePath = config.keystorePath || DEFAULT_KEYSTORE_PATH;
638
- console.error(`[kojee-mcp] Starting proxy for ${config.url}`);
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);
948
+ console.error(
949
+ `[kojee-mcp] Ready \u2014 ${registry.toolCount} tools available from ${config.url}`
950
+ );
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
+ }
1041
+ await startMcpServer(server);
1042
+ }
1043
+ async function enrollAndDiscover(config, keystorePath, isRetry = false) {
639
1044
  const auth = new AuthModule(config.token, config.url, keystorePath);
640
1045
  const keyPair = await auth.ensureEnrolled();
641
1046
  const sessionId = GatewayClient.deriveSessionId(config.token);
@@ -648,18 +1053,26 @@ async function startProxy(config) {
648
1053
  sessionId
649
1054
  );
650
1055
  const registry = new ToolRegistry(gateway);
651
- await registry.discoverTools();
652
- console.error(
653
- `[kojee-mcp] Ready \u2014 ${registry.toolCount} tools available from ${config.url}`
654
- );
655
- const server = createMcpServer(registry);
656
- process.stdin.on("end", () => {
657
- console.error("[kojee-mcp] stdin closed, exiting");
658
- process.exit(0);
659
- });
660
- await startMcpServer(server);
1056
+ try {
1057
+ await registry.discoverTools();
1058
+ return { registry, gateway };
1059
+ } catch (err) {
1060
+ if (isRetry || !isDPoPEnrollmentError(err)) {
1061
+ throw err;
1062
+ }
1063
+ console.error(
1064
+ "[kojee-mcp] Auth failed, attempting recovery with fresh enrollment..."
1065
+ );
1066
+ try {
1067
+ if (fs2.existsSync(keystorePath)) fs2.unlinkSync(keystorePath);
1068
+ } catch (unlinkErr) {
1069
+ console.error("[kojee-mcp] Could not remove stale keystore:", unlinkErr);
1070
+ }
1071
+ return enrollAndDiscover(config, keystorePath, true);
1072
+ }
661
1073
  }
662
1074
 
663
1075
  export {
1076
+ AuthModule,
664
1077
  startProxy
665
1078
  };