@cachly-dev/mcp-server 0.10.5 → 0.10.6
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.
- package/.github/workflows/publish.yml +45 -0
- package/LAUNCH_TEXTS.md +182 -0
- package/LICENSE +202 -0
- package/dist/__tests__/auth.test.d.ts +1 -0
- package/dist/__tests__/auth.test.js +107 -0
- package/dist/__tests__/auth.test.js.map +1 -0
- package/dist/auth.d.ts +3 -0
- package/dist/auth.js +32 -0
- package/dist/auth.js.map +1 -0
- package/dist/handlers/advanced.js +38 -31
- package/dist/handlers/advanced.js.map +1 -1
- package/dist/handlers/brain.js +144 -50
- package/dist/handlers/brain.js.map +1 -1
- package/dist/handlers/fedbrain.js +55 -38
- package/dist/handlers/fedbrain.js.map +1 -1
- package/dist/handlers/roadmap.js +8 -2
- package/dist/handlers/roadmap.js.map +1 -1
- package/dist/handlers/syndicate.js +15 -7
- package/dist/handlers/syndicate.js.map +1 -1
- package/dist/handlers/team.js +62 -50
- package/dist/handlers/team.js.map +1 -1
- package/dist/index.js +222 -217
- package/dist/index.js.map +1 -1
- package/dist/tools.d.ts +0 -8
- package/dist/tools.js +5 -15
- package/dist/tools.js.map +1 -1
- package/dist/utils.d.ts +1 -0
- package/dist/utils.js +13 -0
- package/dist/utils.js.map +1 -0
- package/glama.json +29 -0
- package/llms.txt +155 -0
- package/package.json +29 -13
- package/scripts/postinstall.js +12 -19
- package/server.json +3 -9
- package/smithery.yaml +6 -8
- package/src/__tests__/auth.test.ts +147 -0
- package/src/auth.ts +46 -0
- package/src/handlers/advanced.ts +37 -23
- package/src/handlers/brain.ts +141 -47
- package/src/handlers/fedbrain.ts +43 -31
- package/src/handlers/roadmap.ts +7 -2
- package/src/handlers/syndicate.ts +13 -7
- package/src/handlers/team.ts +57 -33
- package/src/index.ts +224 -234
- package/src/tools.ts +5 -16
- package/src/utils.ts +10 -0
- package/dist/__tests__/brain-predict-failures.test.d.ts +0 -17
- package/dist/__tests__/brain-predict-failures.test.js +0 -339
- package/dist/__tests__/brain-predict-failures.test.js.map +0 -1
- package/src/__tests__/brain-predict-failures.test.ts +0 -430
package/llms.txt
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# cachly AI Brain — MCP Server
|
|
2
|
+
|
|
3
|
+
> Persistent memory layer for AI coding assistants. Gives Claude Code, Cursor, GitHub Copilot, Windsurf, Cline and Zed a permanent brain that learns from every session, bug fix, git commit, and CI run — automatically.
|
|
4
|
+
|
|
5
|
+
## What cachly is
|
|
6
|
+
|
|
7
|
+
cachly is an MCP (Model Context Protocol) server that adds long-term memory to AI coding assistants. Unlike conversation-window context, cachly memory persists forever across sessions, projects, and team members.
|
|
8
|
+
|
|
9
|
+
It works by maintaining a Causal Knowledge Graph (CKG) — a structured store of lessons, bug fixes, architecture decisions, deployment commands, and failure patterns. On every session start, the AI receives a briefing with relevant lessons. On every fix or commit, new lessons are automatically stored.
|
|
10
|
+
|
|
11
|
+
**Key differentiator**: causal_trace — given any error or problem description, traces the causal chain through the graph (root cause → intermediate causes → known fix). No other memory system does this.
|
|
12
|
+
|
|
13
|
+
## Installation (zero config)
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx @cachly-dev/mcp-server@latest setup
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
This command: authenticates via OAuth device flow (one browser click), auto-detects all installed editors, writes the correct MCP config for each, creates CLAUDE.md with Brain rules, installs a git post-commit hook.
|
|
20
|
+
|
|
21
|
+
Free tier: 25 MB, no credit card, German servers (GDPR).
|
|
22
|
+
|
|
23
|
+
## Supported editors
|
|
24
|
+
|
|
25
|
+
Claude Code, Cursor, Windsurf, VS Code, GitHub Copilot, Cline, Zed, Continue.dev
|
|
26
|
+
|
|
27
|
+
## How memory works
|
|
28
|
+
|
|
29
|
+
1. Session starts → AI receives: last session summary, open failures, relevant lessons, memory crystal (compressed history)
|
|
30
|
+
2. During work → AI stores: WIP state, architecture decisions, file summaries
|
|
31
|
+
3. Bug fixed → lesson stored: what failed, what worked, commands used, severity, file paths
|
|
32
|
+
4. Editor closes → session summary saved, git log analyzed, ambient lessons extracted
|
|
33
|
+
5. Next session → AI arrives pre-briefed
|
|
34
|
+
|
|
35
|
+
## Tool catalog (89 tools)
|
|
36
|
+
|
|
37
|
+
### Session & Memory
|
|
38
|
+
- session_start: Single-call session briefing — last summary, open failures, recent lessons, brain health
|
|
39
|
+
- session_end: Save session summary when finishing work
|
|
40
|
+
- session_handoff: Detailed handoff for next chat window
|
|
41
|
+
- session_ping: Lightweight checkpoint every ~5 tool calls
|
|
42
|
+
- auto_learn_session: Auto-learn from session observations without explicit calls
|
|
43
|
+
- sync_file_changes: Associate recent file changes with brain knowledge
|
|
44
|
+
|
|
45
|
+
### Lesson Storage & Recall
|
|
46
|
+
- learn_from_attempts: Store structured lesson from any fix, deploy, or discovery
|
|
47
|
+
- recall_best_solution: Best known solution for a topic with success/failure history
|
|
48
|
+
- smart_recall: BM25+ semantic search across all brain data (11 languages: EN DE FR ES IT PT ZH JA KO AR HE)
|
|
49
|
+
- brain_search: Full-text search over all brain data
|
|
50
|
+
- recall_at: Brain archaeology — see what a lesson looked like at a point in time
|
|
51
|
+
|
|
52
|
+
### Causal Analysis & Prediction
|
|
53
|
+
- causal_trace: Root Cause Analysis through memory — problem → causal chain → known fix
|
|
54
|
+
- brain_predict: Predictive pre-fetch — predicts likely failures given current context, returns fixes
|
|
55
|
+
- brain_predict_failures: Pre-deploy failure prediction with probability percentages
|
|
56
|
+
- trace_dependency: Find all lessons that depend on a given prerequisite
|
|
57
|
+
- knowledge_decay: Confidence scoring for every lesson
|
|
58
|
+
|
|
59
|
+
### Context Management
|
|
60
|
+
- remember_context: Save architecture findings, decisions, file summaries
|
|
61
|
+
- recall_context: Retrieve saved context by key (supports glob)
|
|
62
|
+
- list_remembered: List all cached context entries
|
|
63
|
+
- forget_context: Delete cached context entries
|
|
64
|
+
- compact_recover: Reconstruct full context from Memory Crystal after context limit
|
|
65
|
+
|
|
66
|
+
### Memory Maintenance
|
|
67
|
+
- memory_consolidate: Weekly garbage collector — deduplicates, detects contradictions, expires stale lessons
|
|
68
|
+
- memory_crystalize: Compress sessions into dense Memory Crystal snapshot
|
|
69
|
+
- crystal_view: Inspect current Memory Crystal
|
|
70
|
+
- brain_diff: See exactly what changed in the brain since a point in time
|
|
71
|
+
- brain_doctor: Health check — lesson count, IQ boost %, open failures, recommendations
|
|
72
|
+
- brain_from_git: Bootstrap brain lessons from existing git history
|
|
73
|
+
|
|
74
|
+
### Team Brain
|
|
75
|
+
- team_learn: Store lesson in shared team brain
|
|
76
|
+
- team_recall: Recall from shared team brain (shows who learned what)
|
|
77
|
+
- team_synthesize: Merge multiple contributors' lessons on same topic into canonical version
|
|
78
|
+
- madc_deliberate: Multi-Agent Deliberation Chamber — 6 specialist agents vote on conflicting lessons
|
|
79
|
+
|
|
80
|
+
### Knowledge Commons (Global)
|
|
81
|
+
- syndicate: Contribute verified lesson to global Knowledge Commons
|
|
82
|
+
- syndicate_search: Search global Knowledge Commons
|
|
83
|
+
- syndicate_trending: Trending lessons globally
|
|
84
|
+
- fedbrain_contribute: Contribute with cryptographic knowledge certificate
|
|
85
|
+
- fedbrain_search: Context-weighted search of global commons
|
|
86
|
+
- fedbrain_confirm: Confirm a syndicated lesson worked for you
|
|
87
|
+
- publish_lesson: Publish lesson to Cachly Public Brain
|
|
88
|
+
- import_public_brain: Import community lessons for a framework
|
|
89
|
+
- global_learn: Store lesson that applies across all your projects
|
|
90
|
+
- global_recall: Retrieve cross-project lessons
|
|
91
|
+
|
|
92
|
+
### Continuous Learning Stream
|
|
93
|
+
- cls_ingest: Ingest learning signals from git commits, CI runs, IDE diagnostics without session_end
|
|
94
|
+
- cls_install_hooks: Generate git post-commit hook and GitHub Actions step for automatic learning
|
|
95
|
+
|
|
96
|
+
### Autopilot — self-managing AI instructions
|
|
97
|
+
- autopilot: Generate a CLAUDE.md / copilot-instructions.md / .github/copilot-instructions.md from actual Brain content. Supports Claude, Cursor, Copilot, Windsurf, Gemini. style="minimal" for just the hooks, style="full" for complete ruleset with examples. Re-run as the Brain learns more to continuously upgrade the instructions file.
|
|
98
|
+
|
|
99
|
+
### Roadmap & Planning
|
|
100
|
+
- roadmap_add: Add item to persistent project roadmap stored in Brain
|
|
101
|
+
- roadmap_update: Update roadmap item status, priority, details
|
|
102
|
+
- roadmap_list: List roadmap items filtered by status, priority, tag, milestone
|
|
103
|
+
- roadmap_next: Get the single most important next actionable item
|
|
104
|
+
|
|
105
|
+
### Cache & Infrastructure
|
|
106
|
+
- list_instances / create_instance / delete_instance: Manage Brain instances
|
|
107
|
+
- cache_get / cache_set / cache_delete: Standard Redis/Valkey cache operations
|
|
108
|
+
- cache_mget / cache_mset: Bulk pipeline (single round-trip)
|
|
109
|
+
- cache_exists / cache_ttl / cache_keys / cache_stats: Cache introspection
|
|
110
|
+
- semantic_search: Find cached entries by semantic similarity
|
|
111
|
+
- cache_warmup: Pre-warm semantic cache with prompt/value pairs
|
|
112
|
+
- cache_lock_acquire / cache_lock_release: Distributed locking
|
|
113
|
+
- cache_stream_set / cache_stream_get: LLM token stream caching
|
|
114
|
+
- index_project: Index source files for semantic search
|
|
115
|
+
- detect_namespace: Classify prompts into semantic namespaces
|
|
116
|
+
- get_connection_string: Get Redis/Valkey connection string
|
|
117
|
+
- get_api_status: Full diagnostic — call when anything is broken
|
|
118
|
+
|
|
119
|
+
### Organization & Teams
|
|
120
|
+
- list_orgs / create_org / invite_member / get_org_plan: Multi-tenant organization management
|
|
121
|
+
- ckg_inspect: Inspect Causal Knowledge Graph for a concept
|
|
122
|
+
- autopilot: Generate CLAUDE.md / copilot-instructions.md for self-managing AI
|
|
123
|
+
- setup_ai_memory: One-shot setup of 3-layer AI Memory system
|
|
124
|
+
- brain_federate: Private org knowledge transfer
|
|
125
|
+
|
|
126
|
+
## Comparison to alternatives
|
|
127
|
+
|
|
128
|
+
| Feature | cachly | mem0 | Plain CLAUDE.md |
|
|
129
|
+
|---------|--------|------|-----------------|
|
|
130
|
+
| Persistent across sessions | ✅ | ✅ | Manual |
|
|
131
|
+
| Causal root cause analysis | ✅ | ❌ | ❌ |
|
|
132
|
+
| Automatic (no explicit calls) | ✅ | ❌ | ❌ |
|
|
133
|
+
| Team knowledge sharing | ✅ | Paid | ❌ |
|
|
134
|
+
| Failure prediction | ✅ | ❌ | ❌ |
|
|
135
|
+
| Git-ambient learning | ✅ | ❌ | ❌ |
|
|
136
|
+
| 11-language search | ✅ | ❌ | ❌ |
|
|
137
|
+
| Free tier forever | ✅ | Limited | ✅ |
|
|
138
|
+
| GDPR / EU servers | ✅ | ❌ | ✅ |
|
|
139
|
+
|
|
140
|
+
## Pricing
|
|
141
|
+
|
|
142
|
+
- Free: 25 MB, 1 instance, €0/month forever
|
|
143
|
+
- Dev: 200 MB, €19/month
|
|
144
|
+
- Pro: 900 MB, team features, €49/month
|
|
145
|
+
- Speed: 900 MB + Dragonfly engine, €79/month
|
|
146
|
+
- Business: 7 GB, €199/month
|
|
147
|
+
|
|
148
|
+
All plans: German servers, GDPR-compliant, 99.9% SLA.
|
|
149
|
+
|
|
150
|
+
## Links
|
|
151
|
+
|
|
152
|
+
- npm: https://www.npmjs.com/package/@cachly-dev/mcp-server
|
|
153
|
+
- GitHub: https://github.com/cachly-dev/cachly-mcp
|
|
154
|
+
- Docs: https://cachly.dev/docs/ai-memory
|
|
155
|
+
- Dashboard: https://cachly.dev
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cachly-dev/mcp-server",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.6",
|
|
4
4
|
"mcpName": "io.github.cachly-dev/cachly-mcp",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "Your AI forgets everything between sessions. cachly fixes that permanently. Persistent memory + causal root-cause analysis for Claude Code, Cursor, Copilot & Windsurf. Learns from every bug fix, git commit, and CI run automatically. Arrives pre-briefed. Predicts failures before they happen. 89 MCP tools · Free tier forever · GDPR · EU servers.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"cachly-mcp": "dist/index.js"
|
|
@@ -42,37 +42,53 @@
|
|
|
42
42
|
},
|
|
43
43
|
"keywords": [
|
|
44
44
|
"mcp",
|
|
45
|
+
"mcp-server",
|
|
45
46
|
"model-context-protocol",
|
|
47
|
+
"ai-memory",
|
|
48
|
+
"persistent-memory",
|
|
49
|
+
"claude-code-memory",
|
|
46
50
|
"claude",
|
|
47
51
|
"claude-code",
|
|
48
52
|
"cursor",
|
|
53
|
+
"cursor-memory",
|
|
49
54
|
"copilot",
|
|
50
55
|
"github-copilot",
|
|
51
56
|
"windsurf",
|
|
57
|
+
"cline",
|
|
58
|
+
"zed",
|
|
52
59
|
"continue",
|
|
53
|
-
"ai-memory",
|
|
54
60
|
"ai-brain",
|
|
55
|
-
"persistent-memory",
|
|
56
61
|
"brain",
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
62
|
+
"causal-knowledge-graph",
|
|
63
|
+
"causal-trace",
|
|
64
|
+
"root-cause-analysis",
|
|
65
|
+
"failure-prediction",
|
|
66
|
+
"brain-predict",
|
|
67
|
+
"smart-recall",
|
|
68
|
+
"memory-crystals",
|
|
60
69
|
"team-knowledge",
|
|
61
70
|
"ambient-git",
|
|
62
|
-
"memory-crystals",
|
|
63
|
-
"causal-knowledge-graph",
|
|
64
71
|
"continuous-learning",
|
|
72
|
+
"lessons-learned",
|
|
73
|
+
"session-memory",
|
|
74
|
+
"semantic-cache",
|
|
75
|
+
"semantic-search",
|
|
65
76
|
"predictive-ai",
|
|
77
|
+
"developer-productivity",
|
|
78
|
+
"knowledge-base",
|
|
79
|
+
"knowledge-graph",
|
|
80
|
+
"ai-context",
|
|
81
|
+
"context-window",
|
|
82
|
+
"long-term-memory",
|
|
83
|
+
"llm-memory",
|
|
66
84
|
"cachly",
|
|
67
85
|
"redis",
|
|
68
86
|
"valkey",
|
|
69
87
|
"cache",
|
|
70
88
|
"ai",
|
|
71
89
|
"llm",
|
|
72
|
-
"
|
|
73
|
-
"
|
|
74
|
-
"ckg",
|
|
75
|
-
"mcp-server"
|
|
90
|
+
"gdpr",
|
|
91
|
+
"ckg"
|
|
76
92
|
],
|
|
77
93
|
"homepage": "https://cachly.dev/docs/mcp",
|
|
78
94
|
"bugs": {
|
package/scripts/postinstall.js
CHANGED
|
@@ -2,35 +2,28 @@
|
|
|
2
2
|
// Shown once after: npm install @cachly-dev/mcp-server
|
|
3
3
|
|
|
4
4
|
const c = {
|
|
5
|
-
reset:
|
|
6
|
-
bold:
|
|
5
|
+
reset: "\x1b[0m",
|
|
6
|
+
bold: "\x1b[1m",
|
|
7
7
|
violet: "\x1b[35m",
|
|
8
|
-
green:
|
|
9
|
-
cyan:
|
|
8
|
+
green: "\x1b[32m",
|
|
9
|
+
cyan: "\x1b[36m",
|
|
10
|
+
dim: "\x1b[2m",
|
|
10
11
|
yellow: "\x1b[33m",
|
|
11
|
-
dim: "\x1b[2m",
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
const line = `${c.dim}─────────────────────────────────────────────────────${c.reset}`;
|
|
15
15
|
|
|
16
16
|
console.log(`
|
|
17
17
|
${line}
|
|
18
|
-
${c.bold}${c.violet}🧠
|
|
18
|
+
${c.bold}${c.violet}🧠 cachly AI Brain${c.reset} ${c.dim}v${process.env.npm_package_version ?? ""}${c.reset}
|
|
19
19
|
|
|
20
|
-
${c.bold}
|
|
20
|
+
${c.bold}One command sets everything up:${c.reset}
|
|
21
21
|
|
|
22
|
-
${c.cyan}
|
|
23
|
-
${c.cyan}2.${c.reset} Add to Claude Code / Cursor / Copilot / Windsurf:
|
|
24
|
-
${c.dim}npx @cachly-dev/mcp-server@latest init${c.reset}
|
|
22
|
+
${c.cyan}npx @cachly-dev/mcp-server@latest setup${c.reset}
|
|
25
23
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
${c.dim}──────────────────────────────────────────────────────${c.reset}
|
|
29
|
-
${c.yellow}📦 Related packages:${c.reset}
|
|
30
|
-
${c.cyan}@cachly-dev/openclaw${c.reset} Cut LLM costs 60–90% in JS/TS apps
|
|
31
|
-
${c.dim}npm install @cachly-dev/openclaw${c.reset}
|
|
32
|
-
${c.cyan}cachly${c.reset} CLI to manage your Brain instances
|
|
33
|
-
${c.dim}npm install -g cachly${c.reset}
|
|
24
|
+
Signs you in → detects your editors → writes all MCP configs
|
|
25
|
+
automatically → adds Brain rules to CLAUDE.md.
|
|
34
26
|
|
|
27
|
+
${c.green}No credentials. No credit card. Free forever.${c.reset}
|
|
28
|
+
${c.dim}Other editors: https://cachly.dev/docs/mcp${c.reset}
|
|
35
29
|
${line}`);
|
|
36
|
-
|
package/server.json
CHANGED
|
@@ -1,27 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.cachly-dev/cachly-mcp",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "Your AI forgets everything between sessions. cachly fixes that permanently. A causal brain that learns WHY things break — built from every bug fix, git commit, and CI run, without any explicit calls. Arrives pre-briefed each session. Predicts failures before they happen. Never re-researches the same problem. 89 MCP tools · Free tier · EU servers · GDPR.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"url": "https://github.com/cachly-dev/cachly-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.10.
|
|
9
|
+
"version": "0.10.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"name": "@cachly-dev/mcp-server",
|
|
14
14
|
"identifier": "@cachly-dev/mcp-server",
|
|
15
|
-
"version": "0.10.
|
|
15
|
+
"version": "0.10.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
-
],
|
|
21
|
-
"remotes": [
|
|
22
|
-
{
|
|
23
|
-
"transportType": "streamable-http",
|
|
24
|
-
"url": "https://mcp.cachly.dev/mcp"
|
|
25
|
-
}
|
|
26
20
|
]
|
|
27
21
|
}
|
package/smithery.yaml
CHANGED
|
@@ -5,23 +5,21 @@ startCommand:
|
|
|
5
5
|
properties:
|
|
6
6
|
CACHLY_JWT:
|
|
7
7
|
type: string
|
|
8
|
-
title: "API Key"
|
|
9
|
-
description: "
|
|
10
|
-
default: ""
|
|
8
|
+
title: "API Key (optional — sign in via browser on first use)"
|
|
9
|
+
description: "Leave blank for zero-config: on your first tool call the server opens a browser sign-in (OAuth Device Flow) and auto-provisions your Brain instance. Or pre-fill with your API key from cachly.dev/setup-ai."
|
|
11
10
|
examples:
|
|
12
11
|
- "cky_live_abc123..."
|
|
13
12
|
CACHLY_BRAIN_INSTANCE_ID:
|
|
14
13
|
type: string
|
|
15
|
-
title: "Brain Instance ID"
|
|
16
|
-
description: "
|
|
17
|
-
default: ""
|
|
14
|
+
title: "Brain Instance ID (optional)"
|
|
15
|
+
description: "Only needed if you have multiple Brain instances. Leave blank — the server auto-detects your active instance from the API key."
|
|
18
16
|
pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
|
19
17
|
examples:
|
|
20
18
|
- "9d4077aa-bfa2-468b-89cd-0a8d8f3ec483"
|
|
21
19
|
CACHLY_API_URL:
|
|
22
20
|
type: string
|
|
23
|
-
title: "API URL"
|
|
24
|
-
description: "
|
|
21
|
+
title: "API URL (optional)"
|
|
22
|
+
description: "Leave blank unless you self-host Cachly."
|
|
25
23
|
default: "https://api.cachly.dev"
|
|
26
24
|
required: []
|
|
27
25
|
commandFunction: |-
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import { jwtExpiryMs, checkJwt, handleApiError } from '../auth.js';
|
|
4
|
+
|
|
5
|
+
function makeJwt(payload: Record<string, unknown>): string {
|
|
6
|
+
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
|
7
|
+
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
8
|
+
return `${header}.${body}.fakesig`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const nowSec = () => Math.floor(Date.now() / 1000);
|
|
12
|
+
|
|
13
|
+
describe('jwtExpiryMs', () => {
|
|
14
|
+
it('returns null for an empty string', () => {
|
|
15
|
+
expect(jwtExpiryMs('')).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns null for a plain (non-JWT) string', () => {
|
|
19
|
+
expect(jwtExpiryMs('not-a-token')).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns null when the JWT has only two parts', () => {
|
|
23
|
+
expect(jwtExpiryMs('header.payload')).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns null when the JWT has four parts', () => {
|
|
27
|
+
expect(jwtExpiryMs('a.b.c.d')).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('returns null when the payload base64 is invalid', () => {
|
|
31
|
+
expect(jwtExpiryMs('header.!!!.sig')).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns null when the payload has no exp field', () => {
|
|
35
|
+
expect(jwtExpiryMs(makeJwt({ sub: 'user-123', iat: nowSec() }))).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns null when exp is 0', () => {
|
|
39
|
+
expect(jwtExpiryMs(makeJwt({ exp: 0 }))).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('converts exp (seconds) to milliseconds', () => {
|
|
43
|
+
const expSec = nowSec() + 3600;
|
|
44
|
+
expect(jwtExpiryMs(makeJwt({ exp: expSec }))).toBe(expSec * 1000);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns a past timestamp for an expired token', () => {
|
|
48
|
+
expect(jwtExpiryMs(makeJwt({ exp: nowSec() - 300 }))).toBeLessThan(Date.now());
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns a future timestamp for a valid token', () => {
|
|
52
|
+
expect(jwtExpiryMs(makeJwt({ exp: nowSec() + 86400 }))).toBeGreaterThan(Date.now());
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('handles a token expiring in less than 60 minutes', () => {
|
|
56
|
+
const expSec = nowSec() + 30 * 60;
|
|
57
|
+
const result = jwtExpiryMs(makeJwt({ exp: expSec }))!;
|
|
58
|
+
const minsLeft = Math.floor((result - Date.now()) / 60_000);
|
|
59
|
+
expect(minsLeft).toBeGreaterThanOrEqual(29);
|
|
60
|
+
expect(minsLeft).toBeLessThanOrEqual(30);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('ignores extra claims', () => {
|
|
64
|
+
const expSec = nowSec() + 3600;
|
|
65
|
+
expect(jwtExpiryMs(makeJwt({ sub: 'u', iat: nowSec(), exp: expSec, plan: 'free' }))).toBe(expSec * 1000);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('handles padding-free base64url payloads', () => {
|
|
69
|
+
// base64url (no =) — Buffer.from handles this natively
|
|
70
|
+
const expSec = nowSec() + 7200;
|
|
71
|
+
expect(jwtExpiryMs(makeJwt({ exp: expSec, x: 'a' }))).toBe(expSec * 1000);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('checkJwt', () => {
|
|
76
|
+
it('throws InvalidRequest when jwt is empty', () => {
|
|
77
|
+
expect(() => checkJwt('')).toThrowError(
|
|
78
|
+
expect.objectContaining({ code: ErrorCode.InvalidRequest, message: expect.stringContaining('CACHLY_JWT env var not set') }),
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('throws with setup URL when jwt is missing', () => {
|
|
83
|
+
expect(() => checkJwt('')).toThrowError(
|
|
84
|
+
expect.objectContaining({ message: expect.stringContaining('cachly.dev/setup-ai') }),
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('throws InvalidRequest with expiry timestamp for an expired token', () => {
|
|
89
|
+
const expSec = nowSec() - 60;
|
|
90
|
+
const token = makeJwt({ exp: expSec });
|
|
91
|
+
expect(() => checkJwt(token)).toThrowError(
|
|
92
|
+
expect.objectContaining({
|
|
93
|
+
code: ErrorCode.InvalidRequest,
|
|
94
|
+
message: expect.stringContaining('expired at'),
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('does not throw for a valid non-expired token', () => {
|
|
100
|
+
expect(() => checkJwt(makeJwt({ exp: nowSec() + 3600 }))).not.toThrow();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('does not throw for a token with no exp claim', () => {
|
|
104
|
+
expect(() => checkJwt(makeJwt({ sub: 'user' }))).not.toThrow();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('handleApiError', () => {
|
|
109
|
+
it('throws InvalidRequest for 401 with the detail message', () => {
|
|
110
|
+
expect(() => handleApiError(401, 'token expired')).toThrowError(
|
|
111
|
+
expect.objectContaining({
|
|
112
|
+
code: ErrorCode.InvalidRequest,
|
|
113
|
+
message: expect.stringContaining('Authentication failed (401): token expired'),
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('throws InvalidRequest for 403 with the detail message', () => {
|
|
119
|
+
expect(() => handleApiError(403, 'plan limit reached')).toThrowError(
|
|
120
|
+
expect.objectContaining({
|
|
121
|
+
code: ErrorCode.InvalidRequest,
|
|
122
|
+
message: expect.stringContaining('Access denied (403): plan limit reached'),
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('throws InternalError for other status codes', () => {
|
|
128
|
+
expect(() => handleApiError(500, 'server crash')).toThrowError(
|
|
129
|
+
expect.objectContaining({
|
|
130
|
+
code: ErrorCode.InternalError,
|
|
131
|
+
message: expect.stringContaining('cachly API error 500: server crash'),
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('includes the cachly.dev/setup-ai link for 401 errors', () => {
|
|
137
|
+
expect(() => handleApiError(401, 'Unauthorized')).toThrowError(
|
|
138
|
+
expect.objectContaining({ message: expect.stringContaining('cachly.dev/setup-ai') }),
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('includes account access hint for 403 errors', () => {
|
|
143
|
+
expect(() => handleApiError(403, 'Forbidden')).toThrowError(
|
|
144
|
+
expect.objectContaining({ message: expect.stringContaining('access to this resource') }),
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
});
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
|
|
3
|
+
export function jwtExpiryMs(token: string): number | null {
|
|
4
|
+
try {
|
|
5
|
+
const parts = token.split('.');
|
|
6
|
+
if (parts.length !== 3) return null;
|
|
7
|
+
const payload = JSON.parse(
|
|
8
|
+
Buffer.from(parts[1], 'base64url').toString('utf-8'),
|
|
9
|
+
) as { exp?: number };
|
|
10
|
+
return payload.exp ? payload.exp * 1000 : null;
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function checkJwt(jwt: string): void {
|
|
17
|
+
if (!jwt) {
|
|
18
|
+
throw new McpError(
|
|
19
|
+
ErrorCode.InvalidRequest,
|
|
20
|
+
'CACHLY_JWT env var not set.\n\nGet your token at https://cachly.dev/setup-ai and add it to your MCP config:\n CACHLY_JWT=<your-token>',
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
const expMs = jwtExpiryMs(jwt);
|
|
24
|
+
if (expMs !== null && expMs < Date.now()) {
|
|
25
|
+
throw new McpError(
|
|
26
|
+
ErrorCode.InvalidRequest,
|
|
27
|
+
`CACHLY_JWT expired at ${new Date(expMs).toISOString()}.\n\nGet a fresh token at https://cachly.dev/setup-ai and update CACHLY_JWT in your MCP config.`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function handleApiError(status: number, detail: string): never {
|
|
33
|
+
if (status === 401) {
|
|
34
|
+
throw new McpError(
|
|
35
|
+
ErrorCode.InvalidRequest,
|
|
36
|
+
`Authentication failed (401): ${detail}\n\nYour CACHLY_JWT may be expired or invalid. Get a fresh token at https://cachly.dev/setup-ai`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (status === 403) {
|
|
40
|
+
throw new McpError(
|
|
41
|
+
ErrorCode.InvalidRequest,
|
|
42
|
+
`Access denied (403): ${detail}\n\nCheck that your CACHLY_JWT belongs to an account with access to this resource.`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
throw new McpError(ErrorCode.InternalError, `cachly API error ${status}: ${detail}`);
|
|
46
|
+
}
|