@cachly-dev/mcp-server 0.10.49 → 0.10.56
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 +2 -2
- package/README.md +33 -5
- package/dist/__tests__/auth.test.js.map +1 -0
- package/dist/{src/__tests__ → __tests__}/brain-flow.test.js +0 -141
- package/dist/__tests__/brain-flow.test.js.map +1 -0
- package/dist/__tests__/brain-predict-failures.test.d.ts +17 -0
- package/dist/__tests__/brain-predict-failures.test.js +339 -0
- package/dist/__tests__/brain-predict-failures.test.js.map +1 -0
- package/dist/__tests__/confidence.test.js.map +1 -0
- package/dist/__tests__/e2e/api-auth.test.js.map +1 -0
- package/dist/__tests__/e2e/api-health.test.js.map +1 -0
- package/dist/__tests__/e2e/cli.test.js.map +1 -0
- package/dist/__tests__/e2e/config.js.map +1 -0
- package/dist/__tests__/e2e/funnel.test.js.map +1 -0
- package/dist/__tests__/e2e/mcp-tools.test.js.map +1 -0
- package/dist/__tests__/e2e/provisioning.test.js.map +1 -0
- package/dist/{src/__tests__ → __tests__}/handlers-unit.test.js +2 -0
- package/dist/__tests__/handlers-unit.test.js.map +1 -0
- package/dist/__tests__/namespace.test.js.map +1 -0
- package/dist/auth.js.map +1 -0
- package/dist/ckg.js.map +1 -0
- package/dist/confidence.js.map +1 -0
- package/dist/embeddings.js.map +1 -0
- package/dist/handlers/advanced.js.map +1 -0
- package/dist/{src/handlers → handlers}/brain.js +141 -135
- package/dist/handlers/brain.js.map +1 -0
- package/dist/handlers/cache.js.map +1 -0
- package/dist/{src/handlers → handlers}/context.js +4 -9
- package/dist/handlers/context.js.map +1 -0
- package/dist/{src/handlers → handlers}/fedbrain.d.ts +0 -6
- package/dist/{src/handlers → handlers}/fedbrain.js +7 -19
- package/dist/handlers/fedbrain.js.map +1 -0
- package/dist/handlers/instances.js.map +1 -0
- package/dist/{src/handlers → handlers}/roadmap.js +3 -7
- package/dist/handlers/roadmap.js.map +1 -0
- package/dist/handlers/syndicate.js.map +1 -0
- package/dist/{src/handlers → handlers}/team.js +3 -1
- package/dist/handlers/team.js.map +1 -0
- package/dist/{src/index.js → index.js} +428 -206
- package/dist/index.js.map +1 -0
- package/dist/namespace.js.map +1 -0
- package/dist/search.js.map +1 -0
- package/dist/search.test.js.map +1 -0
- package/dist/{src/tools.d.ts → tools.d.ts} +0 -196
- package/dist/{src/tools.js → tools.js} +0 -178
- package/dist/tools.js.map +1 -0
- package/dist/utils.js.map +1 -0
- package/package.json +3 -3
- package/smithery.yaml +16 -27
- package/src/__tests__/brain-flow.test.ts +0 -174
- package/src/__tests__/brain-predict-failures.test.ts +430 -0
- package/src/__tests__/handlers-unit.test.ts +2 -1
- package/src/handlers/brain.ts +142 -142
- package/src/handlers/context.ts +4 -9
- package/src/handlers/fedbrain.ts +7 -21
- package/src/handlers/roadmap.ts +3 -7
- package/src/handlers/team.ts +3 -1
- package/src/index.ts +415 -221
- package/src/tools.ts +0 -191
- package/tsconfig.json +3 -2
- package/.github/workflows/eas-build-android.yml +0 -58
- package/.github/workflows/eas-update.yml +0 -55
- package/STRATEGY.md +0 -209
- package/apps/travel-chaos-organizer/.env.example +0 -49
- package/apps/travel-chaos-organizer/DEPLOY.md +0 -328
- package/apps/travel-chaos-organizer/Makefile +0 -57
- package/apps/travel-chaos-organizer/README.md +0 -159
- package/apps/travel-chaos-organizer/backend/.env.example +0 -10
- package/apps/travel-chaos-organizer/backend/Dockerfile +0 -25
- package/apps/travel-chaos-organizer/backend/alembic/env.py +0 -56
- package/apps/travel-chaos-organizer/backend/alembic/script.py.mako +0 -24
- package/apps/travel-chaos-organizer/backend/alembic/versions/.gitkeep +0 -0
- package/apps/travel-chaos-organizer/backend/alembic/versions/0001_stripe_customer_id.py +0 -24
- package/apps/travel-chaos-organizer/backend/alembic/versions/0002_telegram_chat_id.py +0 -24
- package/apps/travel-chaos-organizer/backend/alembic.ini +0 -39
- package/apps/travel-chaos-organizer/backend/app/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/app/auth/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/app/auth/keycloak.py +0 -81
- package/apps/travel-chaos-organizer/backend/app/config.py +0 -66
- package/apps/travel-chaos-organizer/backend/app/db/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/app/db/database.py +0 -20
- package/apps/travel-chaos-organizer/backend/app/db/migrations/001_init.sql +0 -71
- package/apps/travel-chaos-organizer/backend/app/db/migrations/002_telemetry_freemium.sql +0 -31
- package/apps/travel-chaos-organizer/backend/app/db/migrations/003_telegram_chat_id.sql +0 -4
- package/apps/travel-chaos-organizer/backend/app/db/migrations/004_stripe_and_email.sql +0 -5
- package/apps/travel-chaos-organizer/backend/app/limiter.py +0 -4
- package/apps/travel-chaos-organizer/backend/app/logging_config.py +0 -25
- package/apps/travel-chaos-organizer/backend/app/main.py +0 -77
- package/apps/travel-chaos-organizer/backend/app/models/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/app/models/schemas.py +0 -142
- package/apps/travel-chaos-organizer/backend/app/routers/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/app/routers/admin.py +0 -237
- package/apps/travel-chaos-organizer/backend/app/routers/cache.py +0 -13
- package/apps/travel-chaos-organizer/backend/app/routers/events.py +0 -29
- package/apps/travel-chaos-organizer/backend/app/routers/export.py +0 -122
- package/apps/travel-chaos-organizer/backend/app/routers/inbox.py +0 -122
- package/apps/travel-chaos-organizer/backend/app/routers/items.py +0 -130
- package/apps/travel-chaos-organizer/backend/app/routers/mail.py +0 -108
- package/apps/travel-chaos-organizer/backend/app/routers/parse.py +0 -303
- package/apps/travel-chaos-organizer/backend/app/routers/payments.py +0 -179
- package/apps/travel-chaos-organizer/backend/app/routers/trips.py +0 -134
- package/apps/travel-chaos-organizer/backend/app/routers/users.py +0 -169
- package/apps/travel-chaos-organizer/backend/app/routers/waitlist.py +0 -37
- package/apps/travel-chaos-organizer/backend/app/services/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/app/services/cache.py +0 -131
- package/apps/travel-chaos-organizer/backend/app/services/drip.py +0 -128
- package/apps/travel-chaos-organizer/backend/app/services/email.py +0 -61
- package/apps/travel-chaos-organizer/backend/app/services/errors.py +0 -44
- package/apps/travel-chaos-organizer/backend/app/services/notifier.py +0 -10
- package/apps/travel-chaos-organizer/backend/app/services/ollama.py +0 -101
- package/apps/travel-chaos-organizer/backend/app/services/parser.py +0 -22
- package/apps/travel-chaos-organizer/backend/app/services/quota.py +0 -85
- package/apps/travel-chaos-organizer/backend/app/services/sentry.py +0 -11
- package/apps/travel-chaos-organizer/backend/app/services/telemetry.py +0 -35
- package/apps/travel-chaos-organizer/backend/pytest.ini +0 -4
- package/apps/travel-chaos-organizer/backend/requirements-dev.txt +0 -6
- package/apps/travel-chaos-organizer/backend/requirements.txt +0 -25
- package/apps/travel-chaos-organizer/backend/tests/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/tests/conftest.py +0 -223
- package/apps/travel-chaos-organizer/backend/tests/test_admin.py +0 -99
- package/apps/travel-chaos-organizer/backend/tests/test_cache.py +0 -142
- package/apps/travel-chaos-organizer/backend/tests/test_drip.py +0 -32
- package/apps/travel-chaos-organizer/backend/tests/test_e2e_chain.py +0 -121
- package/apps/travel-chaos-organizer/backend/tests/test_export.py +0 -48
- package/apps/travel-chaos-organizer/backend/tests/test_inbox.py +0 -107
- package/apps/travel-chaos-organizer/backend/tests/test_items.py +0 -138
- package/apps/travel-chaos-organizer/backend/tests/test_mail.py +0 -113
- package/apps/travel-chaos-organizer/backend/tests/test_ollama.py +0 -145
- package/apps/travel-chaos-organizer/backend/tests/test_parse_url.py +0 -98
- package/apps/travel-chaos-organizer/backend/tests/test_parser.py +0 -72
- package/apps/travel-chaos-organizer/backend/tests/test_payments.py +0 -163
- package/apps/travel-chaos-organizer/backend/tests/test_quota.py +0 -46
- package/apps/travel-chaos-organizer/backend/tests/test_search.py +0 -67
- package/apps/travel-chaos-organizer/backend/tests/test_trips.py +0 -123
- package/apps/travel-chaos-organizer/backend/tests/test_users.py +0 -52
- package/apps/travel-chaos-organizer/backups/.gitkeep +0 -0
- package/apps/travel-chaos-organizer/caddy/Caddyfile +0 -27
- package/apps/travel-chaos-organizer/docker-compose.node1.yml +0 -31
- package/apps/travel-chaos-organizer/docker-compose.override.yml +0 -14
- package/apps/travel-chaos-organizer/docker-compose.yml +0 -128
- package/apps/travel-chaos-organizer/frontend/.env.example +0 -13
- package/apps/travel-chaos-organizer/frontend/app/(app)/_layout.tsx +0 -80
- package/apps/travel-chaos-organizer/frontend/app/(app)/inbox.tsx +0 -166
- package/apps/travel-chaos-organizer/frontend/app/(app)/search.tsx +0 -130
- package/apps/travel-chaos-organizer/frontend/app/(app)/settings.tsx +0 -381
- package/apps/travel-chaos-organizer/frontend/app/(app)/trips/[id].tsx +0 -346
- package/apps/travel-chaos-organizer/frontend/app/(app)/trips.tsx +0 -363
- package/apps/travel-chaos-organizer/frontend/app/(app)/upgrade/cancel.tsx +0 -34
- package/apps/travel-chaos-organizer/frontend/app/(app)/upgrade/success.tsx +0 -35
- package/apps/travel-chaos-organizer/frontend/app/(auth)/login.tsx +0 -91
- package/apps/travel-chaos-organizer/frontend/app/_layout.tsx +0 -71
- package/apps/travel-chaos-organizer/frontend/app/auth.tsx +0 -29
- package/apps/travel-chaos-organizer/frontend/app/index.tsx +0 -16
- package/apps/travel-chaos-organizer/frontend/app/onboarding.tsx +0 -119
- package/apps/travel-chaos-organizer/frontend/app.config.ts +0 -54
- package/apps/travel-chaos-organizer/frontend/assets/adaptive-icon.svg +0 -66
- package/apps/travel-chaos-organizer/frontend/assets/icon.svg +0 -79
- package/apps/travel-chaos-organizer/frontend/assets/splash.svg +0 -15
- package/apps/travel-chaos-organizer/frontend/components/BottomSheet.tsx +0 -102
- package/apps/travel-chaos-organizer/frontend/components/ConfettiCannon.tsx +0 -107
- package/apps/travel-chaos-organizer/frontend/components/ConfirmDialog.tsx +0 -109
- package/apps/travel-chaos-organizer/frontend/components/ErrorBoundary.tsx +0 -45
- package/apps/travel-chaos-organizer/frontend/components/FileUploadButton.tsx +0 -19
- package/apps/travel-chaos-organizer/frontend/components/ImportSheet.tsx +0 -155
- package/apps/travel-chaos-organizer/frontend/components/OfflineBadge.tsx +0 -55
- package/apps/travel-chaos-organizer/frontend/components/QRCodeViewer.tsx +0 -80
- package/apps/travel-chaos-organizer/frontend/components/ScoreRing.tsx +0 -39
- package/apps/travel-chaos-organizer/frontend/components/Screen.tsx +0 -39
- package/apps/travel-chaos-organizer/frontend/components/Skeleton.tsx +0 -70
- package/apps/travel-chaos-organizer/frontend/components/SwipeableInboxItem.tsx +0 -121
- package/apps/travel-chaos-organizer/frontend/components/SwipeableTimelineItem.tsx +0 -70
- package/apps/travel-chaos-organizer/frontend/components/TimelineItem.tsx +0 -73
- package/apps/travel-chaos-organizer/frontend/components/TimelineItemDetail.tsx +0 -130
- package/apps/travel-chaos-organizer/frontend/components/Toast.tsx +0 -60
- package/apps/travel-chaos-organizer/frontend/components/ToastContext.tsx +0 -42
- package/apps/travel-chaos-organizer/frontend/components/TripCard.tsx +0 -146
- package/apps/travel-chaos-organizer/frontend/components/TripQRModal.tsx +0 -70
- package/apps/travel-chaos-organizer/frontend/components/UpgradeBanner.tsx +0 -44
- package/apps/travel-chaos-organizer/frontend/eas.json +0 -22
- package/apps/travel-chaos-organizer/frontend/hooks/useInbox.ts +0 -24
- package/apps/travel-chaos-organizer/frontend/hooks/useNetworkSync.ts +0 -60
- package/apps/travel-chaos-organizer/frontend/hooks/useTrips.ts +0 -112
- package/apps/travel-chaos-organizer/frontend/lib/api.ts +0 -223
- package/apps/travel-chaos-organizer/frontend/lib/auth.ts +0 -93
- package/apps/travel-chaos-organizer/frontend/lib/db.ts +0 -86
- package/apps/travel-chaos-organizer/frontend/lib/fileCache.ts +0 -54
- package/apps/travel-chaos-organizer/frontend/lib/haptics.ts +0 -13
- package/apps/travel-chaos-organizer/frontend/lib/notifications.ts +0 -112
- package/apps/travel-chaos-organizer/frontend/lib/offlineQueue.ts +0 -145
- package/apps/travel-chaos-organizer/frontend/lib/quota.tsx +0 -43
- package/apps/travel-chaos-organizer/frontend/lib/sentry.ts +0 -28
- package/apps/travel-chaos-organizer/frontend/lib/shareIntent.ts +0 -44
- package/apps/travel-chaos-organizer/frontend/package-lock.json +0 -13037
- package/apps/travel-chaos-organizer/frontend/package.json +0 -44
- package/apps/travel-chaos-organizer/frontend/tsconfig.json +0 -23
- package/apps/travel-chaos-organizer/keycloak/realm-export.json +0 -59
- package/apps/travel-chaos-organizer/landing/index.html +0 -773
- package/apps/travel-chaos-organizer/scripts/backup.sh +0 -14
- package/apps/travel-chaos-organizer/scripts/configure-env.sh +0 -82
- package/apps/travel-chaos-organizer/scripts/deploy.sh +0 -54
- package/apps/travel-chaos-organizer/scripts/health-check.sh +0 -36
- package/apps/travel-chaos-organizer/scripts/migrate.sh +0 -7
- package/apps/travel-chaos-organizer/scripts/server-setup.sh +0 -85
- package/apps/travel-chaos-organizer/scripts/smoke-test.sh +0 -29
- package/apps/travel-chaos-organizer/scripts/verify-tracking.sh +0 -71
- package/apps/travel-chaos-organizer/telegram-bot/.env.example +0 -3
- package/apps/travel-chaos-organizer/telegram-bot/Dockerfile +0 -6
- package/apps/travel-chaos-organizer/telegram-bot/bot.py +0 -504
- package/apps/travel-chaos-organizer/telegram-bot/requirements.txt +0 -3
- package/dist/packages/telegram-notify/client.d.ts +0 -19
- package/dist/packages/telegram-notify/client.js +0 -75
- package/dist/packages/telegram-notify/client.js.map +0 -1
- package/dist/src/__tests__/auth.test.js.map +0 -1
- package/dist/src/__tests__/brain-flow.test.js.map +0 -1
- package/dist/src/__tests__/confidence.test.js.map +0 -1
- package/dist/src/__tests__/e2e/api-auth.test.js.map +0 -1
- package/dist/src/__tests__/e2e/api-health.test.js.map +0 -1
- package/dist/src/__tests__/e2e/cli.test.js.map +0 -1
- package/dist/src/__tests__/e2e/config.js.map +0 -1
- package/dist/src/__tests__/e2e/funnel.test.js.map +0 -1
- package/dist/src/__tests__/e2e/mcp-tools.test.js.map +0 -1
- package/dist/src/__tests__/e2e/provisioning.test.js.map +0 -1
- package/dist/src/__tests__/handlers-unit.test.js.map +0 -1
- package/dist/src/__tests__/namespace.test.js.map +0 -1
- package/dist/src/auth.js.map +0 -1
- package/dist/src/ckg.js.map +0 -1
- package/dist/src/confidence.js.map +0 -1
- package/dist/src/embeddings.js.map +0 -1
- package/dist/src/handlers/advanced.js.map +0 -1
- package/dist/src/handlers/brain.js.map +0 -1
- package/dist/src/handlers/cache.js.map +0 -1
- package/dist/src/handlers/context.js.map +0 -1
- package/dist/src/handlers/fedbrain.js.map +0 -1
- package/dist/src/handlers/instances.js.map +0 -1
- package/dist/src/handlers/roadmap.js.map +0 -1
- package/dist/src/handlers/syndicate.js.map +0 -1
- package/dist/src/handlers/tco.d.ts +0 -16
- package/dist/src/handlers/tco.js +0 -303
- package/dist/src/handlers/tco.js.map +0 -1
- package/dist/src/handlers/team.js.map +0 -1
- package/dist/src/index.js.map +0 -1
- package/dist/src/namespace.js.map +0 -1
- package/dist/src/notifier.d.ts +0 -5
- package/dist/src/notifier.js +0 -6
- package/dist/src/notifier.js.map +0 -1
- package/dist/src/search.js.map +0 -1
- package/dist/src/search.test.js.map +0 -1
- package/dist/src/tools.js.map +0 -1
- package/dist/src/utils.js.map +0 -1
- package/packages/telegram-notify/SPLIT_PLAN.md +0 -106
- package/packages/telegram-notify/client.ts +0 -88
- package/packages/telegram-notify/emojis.json +0 -22
- package/packages/telegram-notify/pyproject.toml +0 -17
- package/packages/telegram-notify/telegram_notify/__init__.py +0 -3
- package/packages/telegram-notify/telegram_notify/client.py +0 -87
- package/packages/telegram-notify/telegram_notify/emojis.json +0 -22
- package/scripts/generate-icons.js +0 -28
- package/src/handlers/tco.ts +0 -347
- package/src/notifier.ts +0 -5
- /package/dist/{src/__tests__ → __tests__}/auth.test.d.ts +0 -0
- /package/dist/{src/__tests__ → __tests__}/auth.test.js +0 -0
- /package/dist/{src/__tests__ → __tests__}/brain-flow.test.d.ts +0 -0
- /package/dist/{src/__tests__ → __tests__}/confidence.test.d.ts +0 -0
- /package/dist/{src/__tests__ → __tests__}/confidence.test.js +0 -0
- /package/dist/{src/__tests__ → __tests__}/e2e/api-auth.test.d.ts +0 -0
- /package/dist/{src/__tests__ → __tests__}/e2e/api-auth.test.js +0 -0
- /package/dist/{src/__tests__ → __tests__}/e2e/api-health.test.d.ts +0 -0
- /package/dist/{src/__tests__ → __tests__}/e2e/api-health.test.js +0 -0
- /package/dist/{src/__tests__ → __tests__}/e2e/cli.test.d.ts +0 -0
- /package/dist/{src/__tests__ → __tests__}/e2e/cli.test.js +0 -0
- /package/dist/{src/__tests__ → __tests__}/e2e/config.d.ts +0 -0
- /package/dist/{src/__tests__ → __tests__}/e2e/config.js +0 -0
- /package/dist/{src/__tests__ → __tests__}/e2e/funnel.test.d.ts +0 -0
- /package/dist/{src/__tests__ → __tests__}/e2e/funnel.test.js +0 -0
- /package/dist/{src/__tests__ → __tests__}/e2e/mcp-tools.test.d.ts +0 -0
- /package/dist/{src/__tests__ → __tests__}/e2e/mcp-tools.test.js +0 -0
- /package/dist/{src/__tests__ → __tests__}/e2e/provisioning.test.d.ts +0 -0
- /package/dist/{src/__tests__ → __tests__}/e2e/provisioning.test.js +0 -0
- /package/dist/{src/__tests__ → __tests__}/handlers-unit.test.d.ts +0 -0
- /package/dist/{src/__tests__ → __tests__}/namespace.test.d.ts +0 -0
- /package/dist/{src/__tests__ → __tests__}/namespace.test.js +0 -0
- /package/dist/{src/auth.d.ts → auth.d.ts} +0 -0
- /package/dist/{src/auth.js → auth.js} +0 -0
- /package/dist/{src/ckg.d.ts → ckg.d.ts} +0 -0
- /package/dist/{src/ckg.js → ckg.js} +0 -0
- /package/dist/{src/confidence.d.ts → confidence.d.ts} +0 -0
- /package/dist/{src/confidence.js → confidence.js} +0 -0
- /package/dist/{src/embeddings.d.ts → embeddings.d.ts} +0 -0
- /package/dist/{src/embeddings.js → embeddings.js} +0 -0
- /package/dist/{src/handlers → handlers}/advanced.d.ts +0 -0
- /package/dist/{src/handlers → handlers}/advanced.js +0 -0
- /package/dist/{src/handlers → handlers}/brain.d.ts +0 -0
- /package/dist/{src/handlers → handlers}/cache.d.ts +0 -0
- /package/dist/{src/handlers → handlers}/cache.js +0 -0
- /package/dist/{src/handlers → handlers}/context.d.ts +0 -0
- /package/dist/{src/handlers → handlers}/instances.d.ts +0 -0
- /package/dist/{src/handlers → handlers}/instances.js +0 -0
- /package/dist/{src/handlers → handlers}/roadmap.d.ts +0 -0
- /package/dist/{src/handlers → handlers}/syndicate.d.ts +0 -0
- /package/dist/{src/handlers → handlers}/syndicate.js +0 -0
- /package/dist/{src/handlers → handlers}/team.d.ts +0 -0
- /package/dist/{src/index.d.ts → index.d.ts} +0 -0
- /package/dist/{src/namespace.d.ts → namespace.d.ts} +0 -0
- /package/dist/{src/namespace.js → namespace.js} +0 -0
- /package/dist/{src/search.d.ts → search.d.ts} +0 -0
- /package/dist/{src/search.js → search.js} +0 -0
- /package/dist/{src/search.test.d.ts → search.test.d.ts} +0 -0
- /package/dist/{src/search.test.js → search.test.js} +0 -0
- /package/dist/{src/utils.d.ts → utils.d.ts} +0 -0
- /package/dist/{src/utils.js → utils.js} +0 -0
package/src/index.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { jwtExpiryMs, checkJwt, handleApiError } from './auth.js';
|
|
3
|
-
import { handleTcoTool, TCO_TOOL_NAMES } from './handlers/tco.js';
|
|
4
|
-
import { notify } from './notifier.js';
|
|
5
3
|
import { readFile } from 'node:fs/promises';
|
|
6
|
-
import { execSync } from 'node:child_process';
|
|
7
|
-
import { join } from 'node:path';
|
|
8
4
|
import { fileURLToPath } from 'node:url';
|
|
9
5
|
|
|
10
6
|
// True only when this file is the entry point (not imported by tests or other modules).
|
|
@@ -39,24 +35,11 @@ const _isMain = process.argv[1] != null &&
|
|
|
39
35
|
* ── Auth & Status ────────────────────────────────────────────────────────────
|
|
40
36
|
* • get_api_status – check API health + JWT auth info (Keycloak)
|
|
41
37
|
*
|
|
42
|
-
* ── Travel Chaos Organizer (TCO) ────────────────────────────────────────────
|
|
43
|
-
* • tco_list_trips – list all trips
|
|
44
|
-
* • tco_create_trip – create a new trip
|
|
45
|
-
* • tco_get_timeline – get timeline items for a trip
|
|
46
|
-
* • tco_delete_trip – delete a trip
|
|
47
|
-
* • tco_inbox_list – list Chaos Inbox items
|
|
48
|
-
* • tco_inbox_assign – assign inbox item to a trip
|
|
49
|
-
* • tco_inbox_reject – reject/discard an inbox item
|
|
50
|
-
* • tco_parse_url – fetch a URL and parse with Ollama
|
|
51
|
-
* • tco_import_email – import raw email text
|
|
52
|
-
* (Requires TCO_API_URL env var; auth forwarded from CACHLY_JWT — same Keycloak realm)
|
|
53
|
-
*
|
|
54
38
|
* Configuration (env vars):
|
|
55
39
|
* CACHLY_API_URL – default https://api.cachly.dev
|
|
56
40
|
* CACHLY_JWT – your JWT (Keycloak access token)
|
|
57
41
|
* CACHLY_EMBED_PROVIDER – embedding backend: openai (default), gemini, mistral, cohere, ollama, cachly (server fallback)
|
|
58
42
|
* CACHLY_EMBED_MODEL – override embedding model (optional)
|
|
59
|
-
* TCO_API_URL – Travel Chaos Organizer backend URL (optional, enables tco_* tools)
|
|
60
43
|
*/
|
|
61
44
|
|
|
62
45
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
@@ -69,19 +52,22 @@ import {
|
|
|
69
52
|
McpError,
|
|
70
53
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
71
54
|
import { Redis } from 'ioredis';
|
|
55
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
72
56
|
|
|
73
57
|
// ── Config ────────────────────────────────────────────────────────────────────
|
|
74
58
|
|
|
75
59
|
const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
|
|
76
60
|
let JWT = process.env.CACHLY_JWT ?? '';
|
|
77
|
-
const
|
|
78
|
-
const CURRENT_VERSION = '0.10.49';
|
|
61
|
+
const CURRENT_VERSION = '0.10.55';
|
|
79
62
|
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
63
|
+
// ── Per-request context (multi-tenant HTTP / Smithery) ───────────────────────
|
|
64
|
+
// In stdio mode the module-level JWT/instance globals apply.
|
|
65
|
+
// In HTTP mode, the request handler runs each request in an ALS scope that
|
|
66
|
+
// shadows JWT + instance ID with per-user values extracted from headers.
|
|
67
|
+
interface ReqCtx { jwt: string; instanceId: string; }
|
|
68
|
+
const reqCtx = new AsyncLocalStorage<ReqCtx>();
|
|
69
|
+
function getJWT(): string { return reqCtx.getStore()?.jwt || JWT; }
|
|
70
|
+
function getDefaultInstanceId(): string { return reqCtx.getStore()?.instanceId || _defaultInstanceId; }
|
|
85
71
|
|
|
86
72
|
// ── Default Instance Resolution (for Smithery & single-credential setups) ────
|
|
87
73
|
// When CACHLY_BRAIN_INSTANCE_ID is set, tools can omit the instance_id parameter.
|
|
@@ -91,15 +77,28 @@ let _defaultInstanceId: string = process.env.CACHLY_BRAIN_INSTANCE_ID ?? '';
|
|
|
91
77
|
let _defaultInstanceLastAttempt = 0;
|
|
92
78
|
|
|
93
79
|
async function resolveDefaultInstanceId(): Promise<string> {
|
|
80
|
+
// In an HTTP request scope the instance ID is fixed by the caller's header.
|
|
81
|
+
const scopedId = reqCtx.getStore()?.instanceId;
|
|
82
|
+
if (scopedId) return scopedId;
|
|
94
83
|
if (_defaultInstanceId) return _defaultInstanceId;
|
|
95
|
-
if (!
|
|
84
|
+
if (!getJWT()) return '';
|
|
96
85
|
// Cooldown: don't hammer the API on every tool call after a transient failure.
|
|
86
|
+
// 10 s is enough to avoid rate-limit spam while not blocking the user for too long.
|
|
97
87
|
const now = Date.now();
|
|
98
|
-
if (_defaultInstanceLastAttempt > 0 && now - _defaultInstanceLastAttempt <
|
|
88
|
+
if (_defaultInstanceLastAttempt > 0 && now - _defaultInstanceLastAttempt < 10_000) {
|
|
89
|
+
throw new McpError(
|
|
90
|
+
ErrorCode.InvalidRequest,
|
|
91
|
+
'⚠️ Cachly Brain is temporarily unreachable (API error a few seconds ago).\n\n' +
|
|
92
|
+
'The server will retry automatically in a moment. If this persists:\n' +
|
|
93
|
+
'• Run `get_api_status` for a full diagnostic\n' +
|
|
94
|
+
'• Check that CACHLY_JWT is set and valid\n' +
|
|
95
|
+
'• Visit https://cachly.dev/instances to verify your Brain is running'
|
|
96
|
+
);
|
|
97
|
+
};
|
|
99
98
|
_defaultInstanceLastAttempt = now;
|
|
100
99
|
try {
|
|
101
100
|
const res = await fetch(`${API_URL}/api/v1/instances`, {
|
|
102
|
-
headers: { Authorization: `Bearer ${
|
|
101
|
+
headers: { Authorization: `Bearer ${getJWT()}`, Accept: 'application/json' },
|
|
103
102
|
signal: AbortSignal.timeout(5000),
|
|
104
103
|
});
|
|
105
104
|
if (!res.ok) return '';
|
|
@@ -186,38 +185,28 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
|
|
|
186
185
|
} catch { /* use raw JWT as fallback */ }
|
|
187
186
|
JWT = apiKey;
|
|
188
187
|
setEmbedJwt(apiKey); // keep embeddings.ts in sync
|
|
188
|
+
// Persist API key to ~/.claude/mcp.json so the next editor restart
|
|
189
|
+
// doesn't start over from a missing CACHLY_JWT (fixes BrainRecallCount=0 bug).
|
|
190
|
+
try {
|
|
191
|
+
const { resolve: _res, dirname: _dir } = await import('node:path');
|
|
192
|
+
const { homedir: _hd } = await import('node:os');
|
|
193
|
+
const { writeFile: _wf, mkdir: _mk, readFile: _rf } = await import('node:fs/promises');
|
|
194
|
+
const { existsSync: _ex } = await import('node:fs');
|
|
195
|
+
const globalJwtPath = _res(_hd(), '.claude', 'mcp.json');
|
|
196
|
+
await _mk(_dir(globalJwtPath), { recursive: true });
|
|
197
|
+
let cfg: { mcpServers?: Record<string, { env?: Record<string, string> }> } = {};
|
|
198
|
+
if (_ex(globalJwtPath)) {
|
|
199
|
+
try { cfg = JSON.parse(await _rf(globalJwtPath, 'utf-8')) as typeof cfg; } catch { /* corrupt — start fresh */ }
|
|
200
|
+
}
|
|
201
|
+
cfg.mcpServers ??= {};
|
|
202
|
+
cfg.mcpServers['cachly'] ??= {};
|
|
203
|
+
cfg.mcpServers['cachly'].env ??= {};
|
|
204
|
+
cfg.mcpServers['cachly'].env['CACHLY_JWT'] = apiKey;
|
|
205
|
+
if (_defaultInstanceId) cfg.mcpServers['cachly'].env['CACHLY_BRAIN_INSTANCE_ID'] = _defaultInstanceId;
|
|
206
|
+
await _wf(globalJwtPath, JSON.stringify(cfg, null, 2), 'utf-8');
|
|
207
|
+
} catch { /* non-fatal — editor will just prompt again on next restart */ }
|
|
189
208
|
_deviceFlow = null;
|
|
190
209
|
sendFunnelEvent('device_flow_completed');
|
|
191
|
-
// Persist the API key to ~/.claude/mcp.json so subsequent MCP server restarts
|
|
192
|
-
// keep the token. Without this, JWT is lost on restart and brain_recall_count
|
|
193
|
-
// is never incremented (telemetry has no api_key → no tenant resolution).
|
|
194
|
-
void (async () => {
|
|
195
|
-
try {
|
|
196
|
-
const { writeFile, mkdir, readFile } = await import('node:fs/promises');
|
|
197
|
-
const { existsSync } = await import('node:fs');
|
|
198
|
-
const { resolve, dirname } = await import('node:path');
|
|
199
|
-
const home = process.env.HOME ?? process.env.USERPROFILE ?? '';
|
|
200
|
-
if (!home) return;
|
|
201
|
-
const configPath = resolve(home, '.claude', 'mcp.json');
|
|
202
|
-
let cfg: { mcpServers?: Record<string, { command?: string; args?: string[]; env?: Record<string, string> }> } = {};
|
|
203
|
-
if (existsSync(configPath)) {
|
|
204
|
-
try { cfg = JSON.parse(await readFile(configPath, 'utf-8')) as typeof cfg; } catch { /* corrupt — start fresh */ }
|
|
205
|
-
}
|
|
206
|
-
cfg.mcpServers ??= {};
|
|
207
|
-
const existing = cfg.mcpServers['cachly'];
|
|
208
|
-
if (existing) {
|
|
209
|
-
existing.env ??= {};
|
|
210
|
-
existing.env['CACHLY_JWT'] = apiKey;
|
|
211
|
-
} else {
|
|
212
|
-
cfg.mcpServers['cachly'] = {
|
|
213
|
-
command: 'npx', args: ['-y', '@cachly-dev/mcp-server@latest'],
|
|
214
|
-
env: { CACHLY_JWT: apiKey },
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
await mkdir(dirname(configPath), { recursive: true });
|
|
218
|
-
await writeFile(configPath, JSON.stringify(cfg, null, 2), 'utf-8');
|
|
219
|
-
} catch { /* non-critical — don't break the flow on filesystem errors */ }
|
|
220
|
-
})();
|
|
221
210
|
// Auto-provision: find or create the user's brain instance.
|
|
222
211
|
_defaultInstanceLastAttempt = 0;
|
|
223
212
|
await resolveDefaultInstanceId();
|
|
@@ -225,7 +214,7 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
|
|
|
225
214
|
try {
|
|
226
215
|
const autoRes = await fetch(`${API_URL}/api/v1/instances/auto`, {
|
|
227
216
|
method: 'POST',
|
|
228
|
-
headers: { Authorization: `Bearer ${
|
|
217
|
+
headers: { Authorization: `Bearer ${getJWT()}`, 'Content-Type': 'application/json' },
|
|
229
218
|
signal: AbortSignal.timeout(12000),
|
|
230
219
|
});
|
|
231
220
|
if (autoRes.ok) {
|
|
@@ -240,7 +229,7 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
|
|
|
240
229
|
// Log so telemetry can detect free-tier auto-provision failures
|
|
241
230
|
void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
|
|
242
231
|
method: 'POST',
|
|
243
|
-
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${
|
|
232
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getJWT()}` },
|
|
244
233
|
body: JSON.stringify({ event: 'auto_provision_failed', status: autoRes.status, version: CURRENT_VERSION }),
|
|
245
234
|
signal: AbortSignal.timeout(3000),
|
|
246
235
|
}).catch(() => {});
|
|
@@ -252,7 +241,7 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
|
|
|
252
241
|
if (_defaultInstanceId) {
|
|
253
242
|
try {
|
|
254
243
|
const checkRes = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
|
|
255
|
-
headers: { Authorization: `Bearer ${
|
|
244
|
+
headers: { Authorization: `Bearer ${getJWT()}`, Accept: 'application/json' },
|
|
256
245
|
signal: AbortSignal.timeout(4000),
|
|
257
246
|
});
|
|
258
247
|
if (checkRes.ok) {
|
|
@@ -263,7 +252,7 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
|
|
|
263
252
|
while (checkInst.status === 'provisioning' && Date.now() < pDeadline) {
|
|
264
253
|
await new Promise(r => setTimeout(r, 3000));
|
|
265
254
|
const rr = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
|
|
266
|
-
headers: { Authorization: `Bearer ${
|
|
255
|
+
headers: { Authorization: `Bearer ${getJWT()}`, Accept: 'application/json' },
|
|
267
256
|
signal: AbortSignal.timeout(4000),
|
|
268
257
|
}).catch(() => null);
|
|
269
258
|
if (rr?.ok) {
|
|
@@ -275,51 +264,67 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
|
|
|
275
264
|
}
|
|
276
265
|
} catch { /* non-fatal — getConnection will handle the wait if still provisioning */ }
|
|
277
266
|
}
|
|
278
|
-
// Persist instance_id to ~/.claude/mcp.json after it's known.
|
|
279
|
-
// Without this, every restart loses CACHLY_BRAIN_INSTANCE_ID → resolveDefaultInstanceId
|
|
280
|
-
// makes an extra API call on every startup (with 30s cooldown on failure).
|
|
281
|
-
if (_defaultInstanceId) {
|
|
282
|
-
void (async () => {
|
|
283
|
-
try {
|
|
284
|
-
const { writeFile, readFile } = await import('node:fs/promises');
|
|
285
|
-
const { existsSync } = await import('node:fs');
|
|
286
|
-
const { resolve } = await import('node:path');
|
|
287
|
-
const home = process.env.HOME ?? process.env.USERPROFILE ?? '';
|
|
288
|
-
if (!home) return;
|
|
289
|
-
const configPath = resolve(home, '.claude', 'mcp.json');
|
|
290
|
-
if (!existsSync(configPath)) return;
|
|
291
|
-
let cfg: { mcpServers?: Record<string, { env?: Record<string, string> }> } = {};
|
|
292
|
-
try { cfg = JSON.parse(await readFile(configPath, 'utf-8')) as typeof cfg; } catch { return; }
|
|
293
|
-
const entry = cfg.mcpServers?.['cachly'];
|
|
294
|
-
if (entry?.env) {
|
|
295
|
-
entry.env['CACHLY_BRAIN_INSTANCE_ID'] = _defaultInstanceId;
|
|
296
|
-
await writeFile(configPath, JSON.stringify(cfg, null, 2), 'utf-8');
|
|
297
|
-
}
|
|
298
|
-
} catch { /* non-critical */ }
|
|
299
|
-
})();
|
|
300
|
-
}
|
|
301
267
|
return 'done';
|
|
302
268
|
}
|
|
303
269
|
if (data.error === 'slow_down') flow.pollInterval = Math.min(flow.pollInterval + 2000, 15000);
|
|
304
270
|
return 'pending';
|
|
305
271
|
} catch { return 'pending'; }
|
|
306
272
|
}
|
|
307
|
-
// ──
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
273
|
+
// ── Silent Auto-Setup (instant-trial, zero-click) ─────────────────────────
|
|
274
|
+
// Attempts to provision a free trial silently on the first tool call when no
|
|
275
|
+
// JWT is set. Returns true if credentials were written and the tool can
|
|
276
|
+
// proceed transparently; false if the API is unreachable or trials are full.
|
|
277
|
+
// Only runs once per process (subsequent failures short-circuit immediately).
|
|
278
|
+
let _silentSetupAttempted = false;
|
|
279
|
+
async function silentAutoSetup(): Promise<boolean> {
|
|
280
|
+
if (_silentSetupAttempted) return false;
|
|
281
|
+
_silentSetupAttempted = true;
|
|
282
|
+
try {
|
|
283
|
+
type TrialResp = { api_key: string; expires_at?: string; instance_id?: string; trial?: boolean };
|
|
284
|
+
const res = await fetch(`${API_URL}/auth/instant-trial`, {
|
|
285
|
+
method: 'POST',
|
|
286
|
+
headers: { 'Content-Type': 'application/json' },
|
|
287
|
+
body: JSON.stringify({}),
|
|
288
|
+
signal: AbortSignal.timeout(8000),
|
|
289
|
+
});
|
|
290
|
+
if (!res.ok) return false;
|
|
291
|
+
const data = await res.json() as TrialResp;
|
|
292
|
+
if (!data.api_key) return false;
|
|
312
293
|
|
|
294
|
+
JWT = data.api_key;
|
|
295
|
+
setEmbedJwt(data.api_key);
|
|
296
|
+
if (data.instance_id) _defaultInstanceId = data.instance_id;
|
|
297
|
+
sendFunnelEvent('trial_provisioned', { version: CURRENT_VERSION });
|
|
313
298
|
|
|
299
|
+
// Persist credentials so the next editor restart doesn't re-provision.
|
|
300
|
+
try {
|
|
301
|
+
const { resolve: _rp, dirname: _dn } = await import('node:path');
|
|
302
|
+
const { homedir: _hd } = await import('node:os');
|
|
303
|
+
const { writeFile: _wf, mkdir: _mk, readFile: _rf } = await import('node:fs/promises');
|
|
304
|
+
const { existsSync: _ex } = await import('node:fs');
|
|
305
|
+
const cfgPath = _rp(_hd(), '.claude', 'mcp.json');
|
|
306
|
+
await _mk(_dn(cfgPath), { recursive: true });
|
|
307
|
+
let cfg: { mcpServers?: Record<string, { env?: Record<string, string> }> } = {};
|
|
308
|
+
if (_ex(cfgPath)) {
|
|
309
|
+
try { cfg = JSON.parse(await _rf(cfgPath, 'utf-8')) as typeof cfg; } catch { /* corrupt — overwrite */ }
|
|
310
|
+
}
|
|
311
|
+
cfg.mcpServers ??= {};
|
|
312
|
+
cfg.mcpServers['cachly'] ??= {};
|
|
313
|
+
cfg.mcpServers['cachly'].env ??= {};
|
|
314
|
+
cfg.mcpServers['cachly'].env['CACHLY_JWT'] = data.api_key;
|
|
315
|
+
if (_defaultInstanceId) cfg.mcpServers['cachly'].env['CACHLY_BRAIN_INSTANCE_ID'] = _defaultInstanceId;
|
|
316
|
+
await _wf(cfgPath, JSON.stringify(cfg, null, 2), 'utf-8');
|
|
317
|
+
} catch { /* non-fatal — trial is active for this session even if persist fails */ }
|
|
318
|
+
return true;
|
|
319
|
+
} catch { return false; }
|
|
320
|
+
}
|
|
314
321
|
// ── Embeddings (imported from embeddings.ts) ────────────────────────────────
|
|
315
|
-
import {
|
|
316
|
-
hasEmbedProvider, embedProviderHint } from './embeddings.js';
|
|
322
|
+
import { setEmbedJwt } from './embeddings.js';
|
|
317
323
|
|
|
318
324
|
// ── Search Engine (imported from search.ts) ─────────────────────────────────
|
|
319
325
|
import { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS,
|
|
320
326
|
katakanaToRomaji, arabicLightStem, expandCrossLingual, CROSS_LINGUAL_MAP,
|
|
321
|
-
|
|
322
|
-
import type { KeywordMatch } from './search.js';
|
|
327
|
+
ZERO_RESULTS_LOG, zeroResultsTotal } from './search.js';
|
|
323
328
|
|
|
324
329
|
// ── Exported for testing (re-export from search.ts) ─────────────────────────
|
|
325
330
|
export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS,
|
|
@@ -328,19 +333,6 @@ export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp,
|
|
|
328
333
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
329
334
|
// Instance is imported from handlers/brain.ts
|
|
330
335
|
|
|
331
|
-
interface CreateResponse {
|
|
332
|
-
instance_id: string;
|
|
333
|
-
checkout_url?: string;
|
|
334
|
-
status: string;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
interface SemanticSearchResponse {
|
|
338
|
-
found: boolean;
|
|
339
|
-
id?: string;
|
|
340
|
-
similarity?: number;
|
|
341
|
-
prompt?: string;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
336
|
// ── Connection pool ───────────────────────────────────────────────────────────
|
|
345
337
|
|
|
346
338
|
/** Reuse Redis connections across tool calls (keyed by instance_id). */
|
|
@@ -357,16 +349,27 @@ async function getConnection(instance_id: string): Promise<Redis> {
|
|
|
357
349
|
);
|
|
358
350
|
}
|
|
359
351
|
|
|
360
|
-
if (pool.has(instance_id))
|
|
352
|
+
if (pool.has(instance_id)) {
|
|
353
|
+
const cached = pool.get(instance_id)!;
|
|
354
|
+
// Verify the pooled connection is still alive before reusing it.
|
|
355
|
+
// ioredis 'error' eviction is async — a connection can be in the pool
|
|
356
|
+
// but already internally broken (status 'reconnecting' or 'end').
|
|
357
|
+
const status = (cached as { status?: string }).status;
|
|
358
|
+
if (status === 'ready' || status === 'connect') {
|
|
359
|
+
return cached;
|
|
360
|
+
}
|
|
361
|
+
// Stale — evict and fall through to reconnect.
|
|
362
|
+
pool.delete(instance_id);
|
|
363
|
+
cached.disconnect();
|
|
364
|
+
}
|
|
361
365
|
|
|
362
|
-
// Fetch instance, waiting up to
|
|
363
|
-
//
|
|
364
|
-
// all happen in quick succession and the instance isn't running yet.
|
|
366
|
+
// Fetch instance, waiting up to 8 s if it is still provisioning.
|
|
367
|
+
// (autoStartSession already gave the instance a head-start; 8 s covers any remaining gap.)
|
|
365
368
|
let inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
|
|
366
369
|
if (inst.status === 'provisioning') {
|
|
367
|
-
const deadline = Date.now() +
|
|
370
|
+
const deadline = Date.now() + 8_000;
|
|
368
371
|
while (inst.status === 'provisioning' && Date.now() < deadline) {
|
|
369
|
-
await new Promise(r => setTimeout(r,
|
|
372
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
370
373
|
inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
|
|
371
374
|
}
|
|
372
375
|
}
|
|
@@ -375,7 +378,7 @@ async function getConnection(instance_id: string): Promise<Redis> {
|
|
|
375
378
|
// Telemetry: unreachable instance so the team can proactively investigate
|
|
376
379
|
void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
|
|
377
380
|
method: 'POST',
|
|
378
|
-
headers: { 'Content-Type': 'application/json', ...(
|
|
381
|
+
headers: { 'Content-Type': 'application/json', ...(getJWT() ? { Authorization: `Bearer ${getJWT()}` } : {}) },
|
|
379
382
|
body: JSON.stringify({ event: 'instance_not_reachable', instance_id, status: inst.status, version: CURRENT_VERSION }),
|
|
380
383
|
signal: AbortSignal.timeout(3000),
|
|
381
384
|
}).catch(() => {});
|
|
@@ -406,7 +409,9 @@ async function getConnection(instance_id: string): Promise<Redis> {
|
|
|
406
409
|
|
|
407
410
|
// Fetch connection details (includes password) from the dedicated endpoint.
|
|
408
411
|
let password: string | undefined;
|
|
409
|
-
|
|
412
|
+
// Default to false — most free/dev instances run without TLS.
|
|
413
|
+
// Only enable TLS when the API explicitly returns tls_enabled: true.
|
|
414
|
+
let tlsEnabled = inst.tls_enabled === true;
|
|
410
415
|
try {
|
|
411
416
|
const conn = await apiFetch<{ password?: string; tls_enabled?: boolean }>(
|
|
412
417
|
`/api/v1/instances/${instance_id}/connection`
|
|
@@ -428,11 +433,11 @@ async function getConnection(instance_id: string): Promise<Redis> {
|
|
|
428
433
|
host: inst.host,
|
|
429
434
|
port: inst.port,
|
|
430
435
|
password: password || undefined,
|
|
431
|
-
commandTimeout: 8000,
|
|
432
436
|
...(tlsEnabled ? { tls: {} } : {}),
|
|
433
437
|
lazyConnect: true,
|
|
434
438
|
enableReadyCheck: true,
|
|
435
439
|
connectTimeout: 8000,
|
|
440
|
+
commandTimeout: 10000,
|
|
436
441
|
retryStrategy: () => null, // fail fast, no reconnect loops in MCP context
|
|
437
442
|
});
|
|
438
443
|
|
|
@@ -440,14 +445,46 @@ async function getConnection(instance_id: string): Promise<Redis> {
|
|
|
440
445
|
client.on('error', evict);
|
|
441
446
|
client.on('end', evict);
|
|
442
447
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
448
|
+
// Retry up to 3 times with 2s backoff. Covers the window between K8s PodReady
|
|
449
|
+
// and Valkey actually accepting connections (RDB/AOF load, ~1-3s on cold start).
|
|
450
|
+
let connectErr: unknown;
|
|
451
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
452
|
+
try {
|
|
453
|
+
if (attempt > 0) {
|
|
454
|
+
await new Promise(r => setTimeout(r, 2000 * attempt));
|
|
455
|
+
// Fresh client on retry — ioredis doesn't allow reconnect after explicit disconnect.
|
|
456
|
+
client.disconnect();
|
|
457
|
+
const retryClient = new Redis({
|
|
458
|
+
host: inst.host,
|
|
459
|
+
port: inst.port,
|
|
460
|
+
password: password || undefined,
|
|
461
|
+
...(tlsEnabled ? { tls: {} } : {}),
|
|
462
|
+
lazyConnect: true,
|
|
463
|
+
enableReadyCheck: true,
|
|
464
|
+
connectTimeout: 8000,
|
|
465
|
+
commandTimeout: 10000,
|
|
466
|
+
retryStrategy: () => null,
|
|
467
|
+
});
|
|
468
|
+
retryClient.on('error', () => pool.delete(instance_id));
|
|
469
|
+
retryClient.on('end', () => pool.delete(instance_id));
|
|
470
|
+
await retryClient.connect();
|
|
471
|
+
pool.set(instance_id, retryClient);
|
|
472
|
+
return retryClient;
|
|
473
|
+
}
|
|
474
|
+
await client.connect();
|
|
475
|
+
connectErr = undefined;
|
|
476
|
+
break;
|
|
477
|
+
} catch (err) {
|
|
478
|
+
connectErr = err;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (connectErr) {
|
|
446
483
|
client.disconnect();
|
|
447
|
-
const msg =
|
|
484
|
+
const msg = connectErr instanceof Error ? connectErr.message : String(connectErr);
|
|
448
485
|
void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
|
|
449
486
|
method: 'POST',
|
|
450
|
-
headers: { 'Content-Type': 'application/json', ...(
|
|
487
|
+
headers: { 'Content-Type': 'application/json', ...(getJWT() ? { Authorization: `Bearer ${getJWT()}` } : {}) },
|
|
451
488
|
body: JSON.stringify({ event: 'redis_connect_failed', instance_id, host: inst.host, port: inst.port, error: msg, version: CURRENT_VERSION }),
|
|
452
489
|
signal: AbortSignal.timeout(3000),
|
|
453
490
|
}).catch(() => {});
|
|
@@ -467,14 +504,14 @@ async function getConnection(instance_id: string): Promise<Redis> {
|
|
|
467
504
|
// ── API helper ────────────────────────────────────────────────────────────────
|
|
468
505
|
|
|
469
506
|
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|
470
|
-
checkJwt(
|
|
507
|
+
checkJwt(getJWT());
|
|
471
508
|
const timeoutMs = Number(process.env.CACHLY_API_TIMEOUT_MS ?? 15_000);
|
|
472
509
|
const res = await fetch(`${API_URL}${path}`, {
|
|
473
510
|
...options,
|
|
474
511
|
signal: AbortSignal.timeout(timeoutMs),
|
|
475
512
|
headers: {
|
|
476
513
|
'Content-Type': 'application/json',
|
|
477
|
-
Authorization: `Bearer ${
|
|
514
|
+
Authorization: `Bearer ${getJWT()}`,
|
|
478
515
|
...options.headers,
|
|
479
516
|
},
|
|
480
517
|
});
|
|
@@ -487,11 +524,6 @@ async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T>
|
|
|
487
524
|
return res.json() as Promise<T>;
|
|
488
525
|
}
|
|
489
526
|
|
|
490
|
-
import { detectNamespace } from './namespace.js';
|
|
491
|
-
|
|
492
|
-
// ── CKG (imported from ckg.ts) ─────────────────────────────────────────────
|
|
493
|
-
import type { CKGEdge, CKGNode } from './ckg.js';
|
|
494
|
-
import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge } from './ckg.js';
|
|
495
527
|
import { handleBrainTool } from './handlers/brain.js';
|
|
496
528
|
import { handleContextTool } from './handlers/context.js';
|
|
497
529
|
import { handleInstanceTool } from './handlers/instances.js';
|
|
@@ -500,7 +532,7 @@ import { handleTeamTool } from './handlers/team.js';
|
|
|
500
532
|
import { handleRoadmapTool } from './handlers/roadmap.js';
|
|
501
533
|
import { handleAdvancedTool } from './handlers/advanced.js';
|
|
502
534
|
import { handleSyndicateTool } from './handlers/syndicate.js';
|
|
503
|
-
import { handleFedbrainTool
|
|
535
|
+
import { handleFedbrainTool } from './handlers/fedbrain.js';
|
|
504
536
|
import type { Instance } from './handlers/brain.js';
|
|
505
537
|
|
|
506
538
|
// ── Tools (imported from tools.ts) ─────────────────────────────────────────
|
|
@@ -529,7 +561,10 @@ function sendFunnelEvent(event: string, extra?: Record<string, unknown>): void {
|
|
|
529
561
|
headers: { 'Content-Type': 'application/json' },
|
|
530
562
|
body: JSON.stringify({
|
|
531
563
|
event, version: CURRENT_VERSION, editor: detectEditor(),
|
|
532
|
-
|
|
564
|
+
// Send Cachly API keys (cky_*) for server-side tenant resolution (recall counting, emails).
|
|
565
|
+
// Raw Keycloak JWTs (eyJ...) are intentionally excluded — they are short-lived bearer tokens
|
|
566
|
+
// that should never leave the MCP process boundary.
|
|
567
|
+
...(getJWT().startsWith('cky_') ? { api_key: getJWT() } : {}),
|
|
533
568
|
...extra,
|
|
534
569
|
}),
|
|
535
570
|
signal: AbortSignal.timeout(3000),
|
|
@@ -551,12 +586,20 @@ async function sendAnonymousTelemetry(toolName: string): Promise<void> {
|
|
|
551
586
|
}
|
|
552
587
|
|
|
553
588
|
async function handleTool(name: string, args: Record<string, unknown>): Promise<string> {
|
|
554
|
-
// Guard: if no JWT,
|
|
555
|
-
if (!
|
|
589
|
+
// Guard: if no JWT, try silent auto-setup first, then fall back to device flow.
|
|
590
|
+
if (!getJWT()) {
|
|
591
|
+
// ── Step 1: Silent instant-trial (zero-click) ────────────────────────
|
|
592
|
+
// On the very first tool call, attempt to silently provision a free trial.
|
|
593
|
+
// If it succeeds the user never sees an auth prompt — Brain is just active.
|
|
594
|
+
const provisioned = await silentAutoSetup();
|
|
595
|
+
if (provisioned) {
|
|
596
|
+
return handleTool(name, args); // Transparent re-entry with active credentials
|
|
597
|
+
}
|
|
598
|
+
|
|
556
599
|
void sendAnonymousTelemetry(name);
|
|
557
600
|
|
|
558
|
-
// ── Zero-credential device flow
|
|
559
|
-
// 1st call: start device flow, return code + URL
|
|
601
|
+
// ── Step 2: Zero-credential device flow ─────────────────────────────
|
|
602
|
+
// 1st call after silent setup fails: start device flow, return code + URL
|
|
560
603
|
// 2nd+ calls: poll for token; once authenticated, proceed transparently
|
|
561
604
|
if (_deviceFlow) {
|
|
562
605
|
const result = await pollDeviceFlow(_deviceFlow);
|
|
@@ -630,16 +673,16 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
630
673
|
}
|
|
631
674
|
|
|
632
675
|
// Delegate brain tools (learn, recall, session, etc.)
|
|
633
|
-
|
|
676
|
+
let brainResult = await handleBrainTool(name, args, getConnection, apiFetch);
|
|
634
677
|
if (brainResult !== null) {
|
|
635
|
-
if (!_firstCallSuccessSent &&
|
|
678
|
+
if (!_firstCallSuccessSent && getJWT()) {
|
|
636
679
|
_firstCallSuccessSent = true;
|
|
637
|
-
sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ??
|
|
680
|
+
sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? getDefaultInstanceId() ?? '' });
|
|
638
681
|
}
|
|
639
682
|
// Per-tool telemetry — fires after every successful brain tool call.
|
|
640
683
|
// api_key carries the cky_live_... token so the backend can resolve tenant + increment counters.
|
|
641
|
-
const instanceId = (args.instance_id as string | undefined) ??
|
|
642
|
-
const telemetryExtra =
|
|
684
|
+
const instanceId = (args.instance_id as string | undefined) ?? getDefaultInstanceId() ?? '';
|
|
685
|
+
const telemetryExtra = getJWT() ? { api_key: getJWT(), instance_id: instanceId } : { instance_id: instanceId };
|
|
643
686
|
if (name === 'recall_best_solution') {
|
|
644
687
|
// recall_best_solution → increments BrainRecallCount + triggers level-up logic
|
|
645
688
|
sendFunnelEvent('recall_best_solution', telemetryExtra);
|
|
@@ -655,25 +698,50 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
655
698
|
if (!isFirstSession && resultText.length > 100) {
|
|
656
699
|
// Existing brain — session_start acts as implicit recall → increment counter.
|
|
657
700
|
sendFunnelEvent('recall_best_solution', telemetryExtra);
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Auto-bootstrap brain_from_git when workspace_path is provided and it hasn't run yet.
|
|
704
|
+
// We check a sentinel key in Valkey so this only fires once per workspace path,
|
|
705
|
+
// not just on the very first session (handles cases where brain was empty but
|
|
706
|
+
// session data existed, or where the user re-installed).
|
|
707
|
+
if (args.workspace_path) {
|
|
661
708
|
try {
|
|
662
|
-
const
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
if (
|
|
675
|
-
|
|
709
|
+
const workspacePath = args.workspace_path as string;
|
|
710
|
+
const autoKey = `cachly:brain_from_git:auto_done:${Buffer.from(workspacePath).toString('base64').slice(0, 40)}`;
|
|
711
|
+
const redis = await getConnection(instanceId);
|
|
712
|
+
const alreadyDone = await redis.get(autoKey);
|
|
713
|
+
if (!alreadyDone) {
|
|
714
|
+
// Mark immediately to prevent concurrent runs across multiple MCP tool calls
|
|
715
|
+
await redis.set(autoKey, '1', 'EX', 365 * 86400);
|
|
716
|
+
// Append a placeholder so the user gets immediate feedback, then bootstrap in background.
|
|
717
|
+
// Running brain_from_git synchronously would block session_start for 30-120s on large repos.
|
|
718
|
+
const bgMsg = '\n\n---\n\n🚀 **Auto-indexing your git history in the background** — your Brain will learn from recent commits automatically. No action needed.';
|
|
719
|
+
if (typeof brainResult === 'object' && brainResult !== null && 'content' in brainResult) {
|
|
720
|
+
(brainResult as { content: { type: string; text: string }[] }).content.push({ type: 'text', text: bgMsg });
|
|
721
|
+
} else if (typeof brainResult === 'string') {
|
|
722
|
+
brainResult = brainResult + bgMsg;
|
|
676
723
|
}
|
|
724
|
+
// Fire-and-forget — never block the session_start response
|
|
725
|
+
void handleFedbrainTool('brain_from_git', {
|
|
726
|
+
instance_id: instanceId,
|
|
727
|
+
repo_path: workspacePath,
|
|
728
|
+
limit: 50,
|
|
729
|
+
}, getConnection, apiFetch).then(async (gitBootstrap) => {
|
|
730
|
+
if (gitBootstrap !== null) {
|
|
731
|
+
sendFunnelEvent('brain_from_git', telemetryExtra);
|
|
732
|
+
sendFunnelEvent('recall_best_solution', telemetryExtra);
|
|
733
|
+
// Store ingested count for the next session_start wow moment
|
|
734
|
+
try {
|
|
735
|
+
const ingested = (gitBootstrap as string).match(/Ingested:\s*\*\*(\d+)\*/);
|
|
736
|
+
if (ingested) {
|
|
737
|
+
const r = await getConnection(instanceId);
|
|
738
|
+
await r.set('cachly:brain_from_git:auto_bootstrap_result',
|
|
739
|
+
JSON.stringify({ count: parseInt(ingested[1]!, 10), ts: new Date().toISOString() }),
|
|
740
|
+
'EX', 7 * 86400);
|
|
741
|
+
}
|
|
742
|
+
} catch { /* non-critical — must never break the session */ }
|
|
743
|
+
}
|
|
744
|
+
}).catch(() => { /* non-critical */ });
|
|
677
745
|
}
|
|
678
746
|
} catch { /* git bootstrap errors must never break session_start */ }
|
|
679
747
|
}
|
|
@@ -687,7 +755,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
687
755
|
const srText = typeof brainResult === 'object' && brainResult !== null && 'content' in brainResult
|
|
688
756
|
? JSON.stringify(brainResult)
|
|
689
757
|
: String(brainResult ?? '');
|
|
690
|
-
if (srText.length > 50 && !srText.includes('No lessons found') && !srText.includes('no lessons')
|
|
758
|
+
if (srText.length > 50 && !srText.includes('No lessons found') && !srText.includes('no lessons')) {
|
|
691
759
|
sendFunnelEvent('recall_best_solution', telemetryExtra);
|
|
692
760
|
}
|
|
693
761
|
}
|
|
@@ -697,9 +765,9 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
697
765
|
// Delegate context tools (remember/recall/list/forget)
|
|
698
766
|
const contextResult = await handleContextTool(name, args, getConnection, apiFetch);
|
|
699
767
|
if (contextResult !== null) {
|
|
700
|
-
if (!_firstCallSuccessSent &&
|
|
768
|
+
if (!_firstCallSuccessSent && getJWT()) {
|
|
701
769
|
_firstCallSuccessSent = true;
|
|
702
|
-
sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ??
|
|
770
|
+
sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? getDefaultInstanceId() ?? '' });
|
|
703
771
|
}
|
|
704
772
|
return contextResult;
|
|
705
773
|
}
|
|
@@ -725,8 +793,8 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
725
793
|
if (syndicateResult !== null) {
|
|
726
794
|
// brain_predict is in syndicate handler — track telemetry here.
|
|
727
795
|
if (name === 'brain_predict') {
|
|
728
|
-
const instanceId = (args.instance_id as string | undefined) ??
|
|
729
|
-
const telemetryExtra =
|
|
796
|
+
const instanceId = (args.instance_id as string | undefined) ?? getDefaultInstanceId() ?? '';
|
|
797
|
+
const telemetryExtra = getJWT() ? { api_key: getJWT(), instance_id: instanceId } : { instance_id: instanceId };
|
|
730
798
|
sendFunnelEvent('brain_predict', telemetryExtra);
|
|
731
799
|
}
|
|
732
800
|
return syndicateResult;
|
|
@@ -735,21 +803,16 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
735
803
|
const fedbrainResult = await handleFedbrainTool(name, args, getConnection, apiFetch);
|
|
736
804
|
if (fedbrainResult !== null) {
|
|
737
805
|
// brain_from_git and brain_predict_failures are in fedbrain handler.
|
|
738
|
-
const instanceId = (args.instance_id as string | undefined) ??
|
|
739
|
-
const telemetryExtra =
|
|
806
|
+
const instanceId = (args.instance_id as string | undefined) ?? getDefaultInstanceId() ?? '';
|
|
807
|
+
const telemetryExtra = getJWT() ? { api_key: getJWT(), instance_id: instanceId } : { instance_id: instanceId };
|
|
740
808
|
if (name === 'brain_from_git') {
|
|
741
|
-
|
|
742
|
-
sendFunnelEvent('brain_from_git', { ...telemetryExtra, ...(gitCounts ?? {}) });
|
|
809
|
+
sendFunnelEvent('brain_from_git', telemetryExtra);
|
|
743
810
|
} else if (name === 'brain_predict_failures') {
|
|
744
811
|
sendFunnelEvent('brain_predict_failures', telemetryExtra);
|
|
745
812
|
}
|
|
746
813
|
return fedbrainResult;
|
|
747
814
|
}
|
|
748
815
|
|
|
749
|
-
// Delegate Travel Chaos Organizer tools (requires TCO_API_URL env var)
|
|
750
|
-
const tcoResult = await handleTcoTool(name, args, JWT ?? '');
|
|
751
|
-
if (tcoResult !== null) return tcoResult;
|
|
752
|
-
|
|
753
816
|
switch (name) {
|
|
754
817
|
// ── Instance management ──────────────────────────────────────────────
|
|
755
818
|
case 'get_api_status': {
|
|
@@ -768,7 +831,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
768
831
|
}
|
|
769
832
|
|
|
770
833
|
// Check JWT / auth
|
|
771
|
-
if (!
|
|
834
|
+
if (!getJWT()) {
|
|
772
835
|
return [
|
|
773
836
|
`📡 **cachly API Status**`,
|
|
774
837
|
``,
|
|
@@ -781,28 +844,38 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
781
844
|
}
|
|
782
845
|
|
|
783
846
|
// Decode JWT claims (inspection only, no verification)
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
const
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
847
|
+
const jwt = getJWT();
|
|
848
|
+
let authInfo: string;
|
|
849
|
+
// API keys (cky_live_... / cky_trial_...) are valid long-lived credentials.
|
|
850
|
+
// Only try to decode as JWT (3-part base64url) if it looks like one.
|
|
851
|
+
if (jwt.startsWith('cky_')) {
|
|
852
|
+
const keyType = jwt.startsWith('cky_trial_') ? 'Trial' : 'Live';
|
|
853
|
+
const keyId = jwt.slice(0, 20) + '…';
|
|
854
|
+
authInfo = `✅ API key (${keyType})\n Key prefix: ${keyId}`;
|
|
855
|
+
} else {
|
|
856
|
+
authInfo = '❌ invalid credential format';
|
|
857
|
+
try {
|
|
858
|
+
const parts = jwt.split('.');
|
|
859
|
+
if (parts.length === 3) {
|
|
860
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')) as {
|
|
861
|
+
sub?: string; exp?: number; iss?: string;
|
|
862
|
+
};
|
|
863
|
+
const sub = payload.sub ?? '(unknown)';
|
|
864
|
+
const iss = payload.iss ?? '(unknown)';
|
|
865
|
+
const provider = iss.includes('keycloak') ? 'Keycloak' : 'OIDC';
|
|
866
|
+
const expTs = payload.exp ? new Date(payload.exp * 1000) : null;
|
|
867
|
+
const expired = expTs ? expTs < new Date() : false;
|
|
868
|
+
authInfo = [
|
|
869
|
+
`✅ JWT decoded`,
|
|
870
|
+
` Sub (user ID): ${sub}`,
|
|
871
|
+
` Provider: ${provider}`,
|
|
872
|
+
` Issuer: ${iss}`,
|
|
873
|
+
` Expires: ${expTs ? expTs.toISOString() : 'never'} ${expired ? '⚠️ EXPIRED – get a new token!' : '✅'}`,
|
|
874
|
+
].join('\n');
|
|
875
|
+
}
|
|
876
|
+
} catch {
|
|
877
|
+
authInfo = '❌ JWT decode failed – check CACHLY_JWT format';
|
|
803
878
|
}
|
|
804
|
-
} catch {
|
|
805
|
-
authInfo = '❌ JWT decode failed – check CACHLY_JWT format';
|
|
806
879
|
}
|
|
807
880
|
|
|
808
881
|
// List instances + connectivity
|
|
@@ -816,7 +889,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
816
889
|
const lines: string[] = ['\n\n🧠 **Brain Instances:**'];
|
|
817
890
|
for (const inst of instances) {
|
|
818
891
|
const badge = inst.status === 'running' ? '🟢' : inst.status === 'provisioning' ? '🟡' : '🔴';
|
|
819
|
-
const defaultMark = inst.id ===
|
|
892
|
+
const defaultMark = inst.id === getDefaultInstanceId() ? ' ← active' : '';
|
|
820
893
|
lines.push(` ${badge} **${inst.name}** (\`${inst.id}\`) · ${inst.status}${defaultMark}`);
|
|
821
894
|
if (inst.status === 'running' && inst.host) {
|
|
822
895
|
// Try a quick ping on the cached connection
|
|
@@ -944,12 +1017,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
944
1017
|
|| _defaultInstanceId
|
|
945
1018
|
|| process.env.CACHLY_BRAIN_INSTANCE_ID;
|
|
946
1019
|
if (instanceId) {
|
|
947
|
-
|
|
1020
|
+
// Fire-and-forget — session management is non-critical and must never block tool calls.
|
|
1021
|
+
// The session_start briefing (which includes brain_from_git) can take 30-120s on large
|
|
1022
|
+
// repos; awaiting it here would freeze the user's first MCP tool call entirely.
|
|
1023
|
+
void autoStartSession(instanceId);
|
|
948
1024
|
}
|
|
949
|
-
} else if (_autoSessionStarting) {
|
|
950
|
-
// Another concurrent call is mid-start — wait for it before proceeding.
|
|
951
|
-
await _autoSessionStarting.catch(() => undefined);
|
|
952
1025
|
}
|
|
1026
|
+
// Note: we intentionally do NOT await _autoSessionStarting for any call.
|
|
1027
|
+
// The session tracker runs in background — tool calls must always proceed immediately.
|
|
953
1028
|
if (!sessionTools.has(name)) _autoSessionToolCount++;
|
|
954
1029
|
|
|
955
1030
|
try {
|
|
@@ -957,9 +1032,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
957
1032
|
return { content: [{ type: 'text', text }] };
|
|
958
1033
|
} catch (err) {
|
|
959
1034
|
if (err instanceof McpError) throw err;
|
|
960
|
-
|
|
961
|
-
notify('cachly', 'tool_error', { tool: name, error: msg }).catch(() => undefined);
|
|
962
|
-
throw new McpError(ErrorCode.InternalError, msg);
|
|
1035
|
+
throw new McpError(ErrorCode.InternalError, (err as Error).message);
|
|
963
1036
|
}
|
|
964
1037
|
});
|
|
965
1038
|
|
|
@@ -2021,7 +2094,7 @@ if (process.argv[2] === 'status') {
|
|
|
2021
2094
|
if (process.argv[2] === 'health') {
|
|
2022
2095
|
const { existsSync } = await import('node:fs');
|
|
2023
2096
|
const { readFile } = await import('node:fs/promises');
|
|
2024
|
-
const { resolve
|
|
2097
|
+
const { resolve } = await import('node:path');
|
|
2025
2098
|
|
|
2026
2099
|
const cwd = process.cwd();
|
|
2027
2100
|
let passed = 0;
|
|
@@ -2038,6 +2111,9 @@ if (process.argv[2] === 'health') {
|
|
|
2038
2111
|
const jwt = process.env.CACHLY_JWT ?? '';
|
|
2039
2112
|
if (!jwt) {
|
|
2040
2113
|
fail('CACHLY_JWT not set — run: npx @cachly-dev/mcp-server@latest setup');
|
|
2114
|
+
} else if (jwt.startsWith('cky_')) {
|
|
2115
|
+
// Opaque cachly API key — no JWT expiry to check, format is valid by prefix
|
|
2116
|
+
ok('API key set (cky_*** opaque key)');
|
|
2041
2117
|
} else {
|
|
2042
2118
|
try {
|
|
2043
2119
|
const parts = jwt.split('.');
|
|
@@ -2055,7 +2131,7 @@ if (process.argv[2] === 'health') {
|
|
|
2055
2131
|
ok(`JWT valid${minsLeft !== null ? ` (expires in ${Math.floor(minsLeft / 60)}h ${minsLeft % 60}m)` : ''}`);
|
|
2056
2132
|
}
|
|
2057
2133
|
} catch {
|
|
2058
|
-
fail('CACHLY_JWT format invalid (expected JWT with 3 parts)');
|
|
2134
|
+
fail('CACHLY_JWT format invalid (expected a cky_*** API key or a JWT with 3 parts)');
|
|
2059
2135
|
}
|
|
2060
2136
|
}
|
|
2061
2137
|
|
|
@@ -2103,11 +2179,23 @@ if (process.argv[2] === 'health') {
|
|
|
2103
2179
|
signal: AbortSignal.timeout(5000),
|
|
2104
2180
|
});
|
|
2105
2181
|
if (connRes.ok) {
|
|
2106
|
-
const conn = await connRes.json() as { password?: string; tls_enabled?: boolean };
|
|
2107
|
-
|
|
2108
|
-
|
|
2182
|
+
const conn = await connRes.json() as { password?: string; connection_string?: string; tls_enabled?: boolean };
|
|
2183
|
+
// password may be a direct field, or embedded in connection_string as redis://:pass@host:port
|
|
2184
|
+
if (conn.password) {
|
|
2185
|
+
pingPassword = conn.password;
|
|
2186
|
+
} else if (conn.connection_string) {
|
|
2187
|
+
try {
|
|
2188
|
+
const u = new URL(conn.connection_string);
|
|
2189
|
+
if (u.password) pingPassword = decodeURIComponent(u.password);
|
|
2190
|
+
} catch { /* leave pingPassword undefined */ }
|
|
2191
|
+
}
|
|
2192
|
+
pingTls = conn.tls_enabled === true;
|
|
2193
|
+
} else {
|
|
2194
|
+
warn(`Could not fetch Redis credentials (HTTP ${connRes.status}) — connectivity test may fail`);
|
|
2109
2195
|
}
|
|
2110
|
-
} catch {
|
|
2196
|
+
} catch (connErr) {
|
|
2197
|
+
warn(`Could not fetch Redis credentials — ${(connErr as Error).message}`);
|
|
2198
|
+
}
|
|
2111
2199
|
const pingClient = new Redis({
|
|
2112
2200
|
host: inst.host, port: inst.port,
|
|
2113
2201
|
password: pingPassword,
|
|
@@ -2235,7 +2323,6 @@ if (process.argv[2] === 'setup') {
|
|
|
2235
2323
|
const CLIENT_ID = 'cachly-cli';
|
|
2236
2324
|
|
|
2237
2325
|
console.log('Step 1: Sign in to cachly (free, no credit card)\n');
|
|
2238
|
-
sendFunnelEvent('setup_auth_started');
|
|
2239
2326
|
|
|
2240
2327
|
// Start device flow
|
|
2241
2328
|
let deviceCode = '', userCode = '', verifyUri = '', pollInterval = 5000;
|
|
@@ -2259,7 +2346,6 @@ if (process.argv[2] === 'setup') {
|
|
|
2259
2346
|
console.error('Falling back: sign in at https://cachly.dev/setup-ai and paste your API token.\n');
|
|
2260
2347
|
token = await ask(' Paste API token (cky_live_...): ');
|
|
2261
2348
|
if (!token) { console.error('\nToken is required. Aborting.\n'); rl.close(); process.exit(1); }
|
|
2262
|
-
sendFunnelEvent('setup_auth_completed');
|
|
2263
2349
|
console.log('');
|
|
2264
2350
|
deviceCode = ''; // mark as fallback so we skip polling
|
|
2265
2351
|
}
|
|
@@ -2293,7 +2379,6 @@ if (process.argv[2] === 'setup') {
|
|
|
2293
2379
|
if (tokenData.access_token) {
|
|
2294
2380
|
token = tokenData.access_token;
|
|
2295
2381
|
console.log(' \x1b[32m✓ Authorized!\x1b[0m\n');
|
|
2296
|
-
sendFunnelEvent('setup_auth_completed');
|
|
2297
2382
|
break;
|
|
2298
2383
|
}
|
|
2299
2384
|
// authorization_pending = keep polling; slow_down = increase interval
|
|
@@ -2325,7 +2410,7 @@ if (process.argv[2] === 'setup') {
|
|
|
2325
2410
|
if (!keyBody.key) throw new Error('no key in response');
|
|
2326
2411
|
token = keyBody.key; // swap JWT → cky_live_...
|
|
2327
2412
|
console.log(' ✓\n');
|
|
2328
|
-
} catch
|
|
2413
|
+
} catch {
|
|
2329
2414
|
console.log(' (skipped)\n');
|
|
2330
2415
|
// Non-fatal: fall back to using the Keycloak JWT directly.
|
|
2331
2416
|
// It will expire but setup still works for now.
|
|
@@ -2374,10 +2459,9 @@ if (process.argv[2] === 'setup') {
|
|
|
2374
2459
|
// Newly created — poll until running or give up after 30 s.
|
|
2375
2460
|
const newId = autoBody.instance_id;
|
|
2376
2461
|
console.log(` ✓ created (${newId.slice(0, 8)}…)\n`);
|
|
2377
|
-
// 45 attempts × 3s = 135s — generous for free-tier in slow regions.
|
|
2378
2462
|
process.stdout.write('⏳ Waiting for instance to start');
|
|
2379
|
-
for (let attempt = 0; attempt <
|
|
2380
|
-
await new Promise(r => setTimeout(r,
|
|
2463
|
+
for (let attempt = 0; attempt < 15; attempt++) {
|
|
2464
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
2381
2465
|
process.stdout.write('.');
|
|
2382
2466
|
try {
|
|
2383
2467
|
const checkRes = await fetch(`${API_URL}/api/v1/instances/${newId}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
@@ -2411,7 +2495,6 @@ if (process.argv[2] === 'setup') {
|
|
|
2411
2495
|
console.log(` (Run with --instance-id <id> to use a different one)\n`);
|
|
2412
2496
|
}
|
|
2413
2497
|
console.log(`✓ Instance: ${instance.name} (${instance.id.slice(0, 8)}…)\n`);
|
|
2414
|
-
sendFunnelEvent('setup_instance_ready', { instance_id: instance.id });
|
|
2415
2498
|
|
|
2416
2499
|
// ── Step 3: Detect editors ────────────────────────────────────────────────
|
|
2417
2500
|
const cwd = process.cwd();
|
|
@@ -2584,6 +2667,8 @@ if (process.argv[2] === 'setup') {
|
|
|
2584
2667
|
console.log(` Dashboard: https://cachly.dev/instances/${instance.id}`);
|
|
2585
2668
|
console.log(`\n 📛 Add a live badge to your README:`);
|
|
2586
2669
|
console.log(` npx @cachly-dev/mcp-server@latest badge\n`);
|
|
2670
|
+
console.log(` 🤝 Invite a teammate — they'll arrive pre-briefed on your project:`);
|
|
2671
|
+
console.log(` npx @cachly-dev/mcp-server@latest invite\n`);
|
|
2587
2672
|
|
|
2588
2673
|
// ── Step 7: Email opt-in (non-blocking — at the very end) ────────────────
|
|
2589
2674
|
if (!nonInteractive) {
|
|
@@ -2657,7 +2742,7 @@ if (process.argv[2] === 'index') {
|
|
|
2657
2742
|
const maxFiles = parseInt(flag('max-files') ?? '500', 10);
|
|
2658
2743
|
const namespace = flag('namespace') ?? 'cachly:sem:code';
|
|
2659
2744
|
|
|
2660
|
-
if (!instanceId || !
|
|
2745
|
+
if (!instanceId || !getJWT()) {
|
|
2661
2746
|
console.error('\n❌ CACHLY_BRAIN_INSTANCE_ID and CACHLY_JWT must be set\n');
|
|
2662
2747
|
console.error(' export CACHLY_BRAIN_INSTANCE_ID=<uuid>');
|
|
2663
2748
|
console.error(' export CACHLY_JWT=<cky_live_...>');
|
|
@@ -2699,7 +2784,7 @@ if (process.argv[2] === 'learn-git') {
|
|
|
2699
2784
|
const instanceId = flag('instance-id') ?? process.env.CACHLY_BRAIN_INSTANCE_ID;
|
|
2700
2785
|
const maxCommits = parseInt(flag('max-commits') ?? '50', 10);
|
|
2701
2786
|
|
|
2702
|
-
if (!instanceId || !
|
|
2787
|
+
if (!instanceId || !getJWT()) {
|
|
2703
2788
|
console.error('\n❌ CACHLY_BRAIN_INSTANCE_ID and CACHLY_JWT must be set\n');
|
|
2704
2789
|
console.error(' export CACHLY_BRAIN_INSTANCE_ID=<uuid>');
|
|
2705
2790
|
console.error(' export CACHLY_JWT=<cky_live_...>');
|
|
@@ -2717,7 +2802,7 @@ if (process.argv[2] === 'learn-git') {
|
|
|
2717
2802
|
limit: maxCommits,
|
|
2718
2803
|
});
|
|
2719
2804
|
console.log(result);
|
|
2720
|
-
sendFunnelEvent('brain_from_git', {
|
|
2805
|
+
sendFunnelEvent('brain_from_git', { instance_id: instanceId });
|
|
2721
2806
|
console.log('\n✅ Brain learned from your commits.\n');
|
|
2722
2807
|
// Let fire-and-forget telemetry flush before exit.
|
|
2723
2808
|
await new Promise(r => setTimeout(r, 300));
|
|
@@ -2734,7 +2819,8 @@ if (process.argv[2] === 'learn-git') {
|
|
|
2734
2819
|
// actionable message in their editor's MCP log instead of silent failures.
|
|
2735
2820
|
// Skip for CLI commands that intentionally run without credentials.
|
|
2736
2821
|
const _cliNoAuthCommands = ['demo', 'share', 'health', 'setup', 'init', 'digest', 'invite', 'badge', 'join', 'upgrade'];
|
|
2737
|
-
|
|
2822
|
+
// In multi-tenant HTTP mode (PORT set) we never need a startup JWT — per-request auth.
|
|
2823
|
+
if (!JWT && !process.env.PORT && !_cliNoAuthCommands.includes(process.argv[2] ?? '') && !(!process.argv[2] && process.stdout.isTTY)) {
|
|
2738
2824
|
// No args + no JWT in a non-TTY context = running as MCP server without credentials.
|
|
2739
2825
|
// Print actionable setup banner to stdout so it's captured by callers/tests.
|
|
2740
2826
|
const banner =
|
|
@@ -2807,12 +2893,76 @@ if (!process.env.CACHLY_NO_UPDATE_CHECK) {
|
|
|
2807
2893
|
|
|
2808
2894
|
const httpPort = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined;
|
|
2809
2895
|
|
|
2896
|
+
// ── Per-request MCP Server factory (HTTP / Smithery stateless mode) ──────────
|
|
2897
|
+
// SDK ≥1.9 forbids reusing a stateless transport across requests.
|
|
2898
|
+
// Solution: create a fresh Server+Transport per /mcp request.
|
|
2899
|
+
// The module-level `server` below is used for stdio mode only.
|
|
2900
|
+
function createHttpMcpServer(): Server {
|
|
2901
|
+
const srv = new Server(
|
|
2902
|
+
{ name: 'cachly-mcp', version: CURRENT_VERSION },
|
|
2903
|
+
{ capabilities: { tools: {} } }
|
|
2904
|
+
);
|
|
2905
|
+
srv.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
2906
|
+
srv.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
2907
|
+
const { name, arguments: args } = request.params;
|
|
2908
|
+
try {
|
|
2909
|
+
const text = await handleTool(name, (args ?? {}) as Record<string, unknown>);
|
|
2910
|
+
return { content: [{ type: 'text', text }] };
|
|
2911
|
+
} catch (err) {
|
|
2912
|
+
if (err instanceof McpError) throw err;
|
|
2913
|
+
throw new McpError(ErrorCode.InternalError, (err as Error).message);
|
|
2914
|
+
}
|
|
2915
|
+
});
|
|
2916
|
+
return srv;
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2810
2919
|
if (httpPort) {
|
|
2811
2920
|
// ── HTTP mode (Streamable HTTP transport) ───────────────────────────────
|
|
2812
2921
|
// Used for Smithery URL deployment: PORT=3000 node dist/index.js
|
|
2922
|
+
// Multi-tenant: per-request JWT + instance ID resolved via:
|
|
2923
|
+
// 1. Authorization: Bearer <jwt> + X-Cachly-Instance-Id: <uuid>
|
|
2924
|
+
// 2. Smithery config: ?config=base64url({CACHLY_JWT, CACHLY_BRAIN_INSTANCE_ID, ...})
|
|
2925
|
+
// Falls back to module-level env-derived values when neither is provided.
|
|
2813
2926
|
const { createServer } = await import('node:http');
|
|
2814
|
-
|
|
2815
|
-
|
|
2927
|
+
|
|
2928
|
+
function extractReqCtx(req: import('node:http').IncomingMessage, url: URL): ReqCtx {
|
|
2929
|
+
let jwtVal = '';
|
|
2930
|
+
let instId = '';
|
|
2931
|
+
const auth = req.headers['authorization'];
|
|
2932
|
+
if (typeof auth === 'string' && auth.trim()) {
|
|
2933
|
+
const a = auth.trim();
|
|
2934
|
+
jwtVal = a.toLowerCase().startsWith('bearer ') ? a.slice(7).trim() : a;
|
|
2935
|
+
}
|
|
2936
|
+
// Fallback: raw JWT in custom header (Smithery "header" transport without Bearer prefix)
|
|
2937
|
+
if (!jwtVal) {
|
|
2938
|
+
const rawJwt = req.headers['cachly_jwt'] ?? req.headers['cachly-jwt'] ?? req.headers['x-cachly-jwt'] ?? req.headers['x-api-key'];
|
|
2939
|
+
if (typeof rawJwt === 'string') jwtVal = rawJwt.trim();
|
|
2940
|
+
}
|
|
2941
|
+
const hdrInst = req.headers['x-cachly-instance-id'];
|
|
2942
|
+
if (typeof hdrInst === 'string') instId = hdrInst.trim();
|
|
2943
|
+
// Smithery passes user config as a base64url-encoded JSON blob in ?config=
|
|
2944
|
+
const cfgParam = url.searchParams.get('config');
|
|
2945
|
+
if (cfgParam && (!jwtVal || !instId)) {
|
|
2946
|
+
try {
|
|
2947
|
+
const b64 = cfgParam.replace(/-/g, '+').replace(/_/g, '/');
|
|
2948
|
+
const padded = b64 + '='.repeat((4 - b64.length % 4) % 4);
|
|
2949
|
+
const json = JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
|
|
2950
|
+
if (!jwtVal && typeof json.CACHLY_JWT === 'string') jwtVal = json.CACHLY_JWT;
|
|
2951
|
+
if (!instId && typeof json.CACHLY_BRAIN_INSTANCE_ID === 'string') instId = json.CACHLY_BRAIN_INSTANCE_ID;
|
|
2952
|
+
} catch { /* malformed config — ignore, fall back to globals */ }
|
|
2953
|
+
}
|
|
2954
|
+
// Direct query params (Smithery "query" transport, manual clients, etc.)
|
|
2955
|
+
if (!jwtVal) {
|
|
2956
|
+
const q = url.searchParams.get('CACHLY_JWT') ?? url.searchParams.get('cachly_jwt') ?? url.searchParams.get('api_key');
|
|
2957
|
+
if (q) jwtVal = q.trim();
|
|
2958
|
+
}
|
|
2959
|
+
if (!instId) {
|
|
2960
|
+
const q = url.searchParams.get('CACHLY_BRAIN_INSTANCE_ID') ?? url.searchParams.get('cachly_brain_instance_id') ?? url.searchParams.get('instance_id');
|
|
2961
|
+
if (q) instId = q.trim();
|
|
2962
|
+
}
|
|
2963
|
+
return { jwt: jwtVal, instanceId: instId };
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2816
2966
|
const httpServer = createServer((req, res) => {
|
|
2817
2967
|
const url = new URL(req.url ?? '/', `http://localhost:${httpPort}`);
|
|
2818
2968
|
if (url.pathname === '/health') {
|
|
@@ -2820,6 +2970,8 @@ if (httpPort) {
|
|
|
2820
2970
|
res.end(JSON.stringify({
|
|
2821
2971
|
status: 'ok',
|
|
2822
2972
|
version: CURRENT_VERSION,
|
|
2973
|
+
transport: 'streamable-http',
|
|
2974
|
+
multiTenant: true,
|
|
2823
2975
|
zeroResults: {
|
|
2824
2976
|
total: zeroResultsTotal,
|
|
2825
2977
|
last10: ZERO_RESULTS_LOG.slice(-10).map(e => ({ query: e.query, ts: new Date(e.ts).toISOString() })),
|
|
@@ -2827,8 +2979,52 @@ if (httpPort) {
|
|
|
2827
2979
|
}));
|
|
2828
2980
|
return;
|
|
2829
2981
|
}
|
|
2982
|
+
// Static server card — lets Smithery skip live scanning (avoids auth complexity).
|
|
2983
|
+
if (url.pathname === '/.well-known/mcp/server-card.json') {
|
|
2984
|
+
const card = {
|
|
2985
|
+
name: 'cachly AI Brain',
|
|
2986
|
+
version: CURRENT_VERSION,
|
|
2987
|
+
description: 'Persistent AI memory for Claude Code, Cursor, Copilot, Windsurf & friends. Remembers every lesson, fix, and architecture decision forever.',
|
|
2988
|
+
transport: { type: 'streamable-http', url: 'https://mcp.cachly.dev/mcp' },
|
|
2989
|
+
capabilities: { tools: {} },
|
|
2990
|
+
};
|
|
2991
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
2992
|
+
res.end(JSON.stringify(card));
|
|
2993
|
+
return;
|
|
2994
|
+
}
|
|
2830
2995
|
if (url.pathname === '/mcp' || url.pathname === '/') {
|
|
2831
|
-
|
|
2996
|
+
// Handle CORS preflight
|
|
2997
|
+
if (req.method === 'OPTIONS') {
|
|
2998
|
+
res.writeHead(204, {
|
|
2999
|
+
'Access-Control-Allow-Origin': '*',
|
|
3000
|
+
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
|
3001
|
+
'Access-Control-Allow-Headers': 'Authorization, Content-Type, Mcp-Session-Id, X-Cachly-Instance-Id, cachly_jwt, cachly-jwt, x-cachly-jwt, x-api-key',
|
|
3002
|
+
});
|
|
3003
|
+
res.end();
|
|
3004
|
+
return;
|
|
3005
|
+
}
|
|
3006
|
+
const ctx = extractReqCtx(req, url);
|
|
3007
|
+
// Create a fresh Server+Transport for each request (stateless multi-tenant mode).
|
|
3008
|
+
// SDK ≥1.9 throws if a stateless transport is reused across requests.
|
|
3009
|
+
const reqServer = createHttpMcpServer();
|
|
3010
|
+
const reqTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
3011
|
+
reqServer.connect(reqTransport).then(() => {
|
|
3012
|
+
reqCtx.run(ctx, () => {
|
|
3013
|
+
reqTransport.handleRequest(req, res).catch((err) => {
|
|
3014
|
+
process.stderr.write(`cachly-mcp HTTP handler error: ${(err as Error).message}\n`);
|
|
3015
|
+
if (!res.headersSent) {
|
|
3016
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
3017
|
+
res.end(JSON.stringify({ error: 'internal_error' }));
|
|
3018
|
+
}
|
|
3019
|
+
});
|
|
3020
|
+
});
|
|
3021
|
+
}).catch((err) => {
|
|
3022
|
+
process.stderr.write(`cachly-mcp connect error: ${(err as Error).message}\n`);
|
|
3023
|
+
if (!res.headersSent) {
|
|
3024
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
3025
|
+
res.end(JSON.stringify({ error: 'internal_error' }));
|
|
3026
|
+
}
|
|
3027
|
+
});
|
|
2832
3028
|
return;
|
|
2833
3029
|
}
|
|
2834
3030
|
res.writeHead(404);
|
|
@@ -2843,5 +3039,3 @@ if (httpPort) {
|
|
|
2843
3039
|
await server.connect(transport);
|
|
2844
3040
|
}
|
|
2845
3041
|
|
|
2846
|
-
notify('cachly', 'startup', { version: CURRENT_VERSION, mode: process.env.MCP_HTTP_PORT ? 'http' : 'stdio' }).catch(() => undefined);
|
|
2847
|
-
|