From 9cc92de6d137d2c599a6a7ddec6ed3a688cac1b6 Mon Sep 17 00:00:00 2001 From: Jaeyoun Nam Date: Mon, 20 Jul 2026 18:25:52 -0700 Subject: [PATCH 1/2] feat(data-sources): add native Jam MCP provider --- bun.lock | 32 +++- package.json | 1 + packages/db/src/credentials.ts | 9 + packages/db/src/jam-credentials.test.ts | 37 ++++ .../__snapshots__/data-sources.test.ts.snap | 2 + packages/db/src/source-providers.ts | 29 +++ packages/server/package.json | 1 + .../server/src/services/data-source-tester.ts | 6 + .../server/src/services/jam-mcp-client.ts | 99 ++++++++++ .../src/services/testers/jam-tester.test.ts | 48 +++++ .../server/src/services/testers/jam-tester.ts | 58 ++++++ .../adapters/jam-source-api-contract.ts | 176 ++++++++++++++++++ .../src/source-api/adapters/jam.test.ts | 145 +++++++++++++++ .../server/src/source-api/adapters/jam.ts | 95 ++++++++++ packages/server/src/source-api/registry.ts | 2 + .../ui/src/data-sources/provider-icons.tsx | 6 + 16 files changed, 736 insertions(+), 10 deletions(-) create mode 100644 packages/db/src/jam-credentials.test.ts create mode 100644 packages/server/src/services/jam-mcp-client.ts create mode 100644 packages/server/src/services/testers/jam-tester.test.ts create mode 100644 packages/server/src/services/testers/jam-tester.ts create mode 100644 packages/server/src/source-api/adapters/jam-source-api-contract.ts create mode 100644 packages/server/src/source-api/adapters/jam.test.ts create mode 100644 packages/server/src/source-api/adapters/jam.ts diff --git a/bun.lock b/bun.lock index 90d0a2f4..22ec90a4 100644 --- a/bun.lock +++ b/bun.lock @@ -383,6 +383,7 @@ "@bufbuild/protovalidate": "catalog:", "@hono/structured-logger": "catalog:", "@hono/zod-validator": "catalog:", + "@modelcontextprotocol/sdk": "catalog:", "@noble/ciphers": "2.1.1", "@onequery/audit-contracts": "workspace:*", "@onequery/base": "workspace:*", @@ -469,6 +470,7 @@ "@hono/node-server": "2.0.1", "@hono/structured-logger": "0.1.0", "@hono/zod-validator": "0.7.6", + "@modelcontextprotocol/sdk": "1.27.1", "@nanostores/react": "1.1.0", "@tabler/icons-react": "3.36.1", "@tanstack/react-query": "5.90.20", @@ -1270,7 +1272,7 @@ "@mistralai/mistralai": ["@mistralai/mistralai@1.14.1", "", { "dependencies": { "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.1" } }, "sha512-IiLmmZFCCTReQgPAT33r7KQ1nYo5JPdvGkrkZqA8qQ2qB1GHgs5LoP5K2ICyrjnpw2n8oSxMM/VP+liiKcGNlQ=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="], "@module-federation/error-codes": ["@module-federation/error-codes@0.22.0", "", {}, "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug=="], @@ -3146,7 +3148,7 @@ "isbot": ["isbot@5.1.38", "", {}, "sha512-Cus2702JamTNMEY4zTP+TShgq/3qzjvGcBC4XMOV45BLaxD4iUFENkqu7ZhFeSzwNsCSZLjnGlihDQznnpnEEA=="], - "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], @@ -4368,7 +4370,7 @@ "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], - "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="], @@ -4854,6 +4856,8 @@ "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + "@dotenvx/dotenvx/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], + "@ecies/ciphers/@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], "@emulators/core/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], @@ -4924,9 +4928,7 @@ "@modelcontextprotocol/sdk/@hono/node-server": ["@hono/node-server@1.19.13", "", { "peerDependencies": { "hono": "^4" } }, "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ=="], - "@modelcontextprotocol/sdk/hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="], - - "@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], "@noble/curves/@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], @@ -5122,8 +5124,6 @@ "command-line-usage/typical": ["typical@7.3.0", "", {}, "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw=="], - "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], "degenerator/ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], @@ -5218,6 +5218,8 @@ "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + "openclaw/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "openclaw/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], "openclaw/hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="], @@ -5274,6 +5276,8 @@ "send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "shadcn/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "shadcn/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "shadcn/postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="], @@ -5492,6 +5496,8 @@ "@dotenvx/dotenvx/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + "@dotenvx/dotenvx/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -5732,8 +5738,6 @@ "color/color-convert/color-name": ["color-name@2.1.0", "", {}, "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg=="], - "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], "drizzle-kit/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], @@ -5816,6 +5820,8 @@ "node-edge-tts/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "openclaw/@modelcontextprotocol/sdk/@hono/node-server": ["@hono/node-server@1.19.13", "", { "peerDependencies": { "hono": "^4" } }, "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ=="], + "ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -5830,6 +5836,12 @@ "send/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "shadcn/@modelcontextprotocol/sdk/@hono/node-server": ["@hono/node-server@1.19.13", "", { "peerDependencies": { "hono": "^4" } }, "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ=="], + + "shadcn/@modelcontextprotocol/sdk/hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="], + + "shadcn/@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "shadcn/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "sitemap/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], diff --git a/package.json b/package.json index 8682af07..cc68f419 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@hono/node-server": "2.0.1", "@hono/structured-logger": "0.1.0", "@hono/zod-validator": "0.7.6", + "@modelcontextprotocol/sdk": "1.27.1", "@nanostores/react": "1.1.0", "@tabler/icons-react": "3.36.1", "@tanstack/react-query": "5.90.20", diff --git a/packages/db/src/credentials.ts b/packages/db/src/credentials.ts index 241cfca6..c83bd2ff 100644 --- a/packages/db/src/credentials.ts +++ b/packages/db/src/credentials.ts @@ -396,6 +396,13 @@ export const ConfluenceCredentialsSchema = z.object({ export type ConfluenceCredentials = z.infer; +export const JamCredentialsSchema = z.object({ + accessToken: requiredOpaqueString("Personal access token is required"), + type: z.literal("jam"), +}); + +export type JamCredentials = z.infer; + export const AmazonAdsCredentialsSchema = z.object({ accessToken: requiredOpaqueString("Access token is required"), apiBaseUrl: optionalTrimmedUrl("API base URL must be a valid URL"), @@ -704,6 +711,7 @@ export const CredentialsSchema = z.union([ GranolaCredentialsSchema, GoogleSearchConsoleCredentialsSchema, ConfluenceCredentialsSchema, + JamCredentialsSchema, AmazonAdsCredentialsSchema, LinkedInAdsCredentialsSchema, TikTokMarketingCredentialsSchema, @@ -776,6 +784,7 @@ export const credentialSchemaMap = { google_search_console: GoogleSearchConsoleCredentialsSchema, granola: GranolaCredentialsSchema, hermes: HermesCredentialsSchema, + jam: JamCredentialsSchema, jira: JiraCredentialsSchema, linkedin_ads: LinkedInAdsCredentialsSchema, cloudflare_workers_observability: diff --git a/packages/db/src/jam-credentials.test.ts b/packages/db/src/jam-credentials.test.ts new file mode 100644 index 00000000..569f03ca --- /dev/null +++ b/packages/db/src/jam-credentials.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { JamCredentialsSchema } from "./credentials"; +import { safeParseSourceProviderCredentials } from "./source-providers"; + +describe("Jam credentials", () => { + it("accepts a non-empty personal access token", () => { + expect( + JamCredentialsSchema.safeParse({ + accessToken: "jam_pat_secret", + type: "jam", + }).success + ).toBe(true); + }); + + it("rejects an empty personal access token", () => { + expect( + JamCredentialsSchema.safeParse({ accessToken: " ", type: "jam" }) + .success + ).toBe(false); + }); + + it("injects the Jam credential discriminator from the provider", () => { + const result = safeParseSourceProviderCredentials({ + credentials: { accessToken: "jam_pat_secret" }, + provider: "jam", + }); + + expect(result).toMatchObject({ + data: { + credentials: { accessToken: "jam_pat_secret", type: "jam" }, + provider: "jam", + }, + success: true, + }); + }); +}); diff --git a/packages/db/src/schema/__snapshots__/data-sources.test.ts.snap b/packages/db/src/schema/__snapshots__/data-sources.test.ts.snap index 9d32f00c..4f2671b7 100644 --- a/packages/db/src/schema/__snapshots__/data-sources.test.ts.snap +++ b/packages/db/src/schema/__snapshots__/data-sources.test.ts.snap @@ -29,6 +29,7 @@ exports[`data-sources schema > matches provider type snapshots 1`] = ` "granola", "google_search_console", "confluence", + "jam", "amazon_ads", "linkedin_ads", "tiktok_marketing", @@ -70,6 +71,7 @@ exports[`data-sources schema > matches provider type snapshots 1`] = ` "granola", "google_search_console", "confluence", + "jam", "amazon_ads", "linkedin_ads", "tiktok_marketing", diff --git a/packages/db/src/source-providers.ts b/packages/db/src/source-providers.ts index d1e92221..1c68e506 100644 --- a/packages/db/src/source-providers.ts +++ b/packages/db/src/source-providers.ts @@ -20,6 +20,7 @@ import { GoogleAnalyticsCredentialsSchema, GranolaCredentialsSchema, HermesCredentialsSchema, + JamCredentialsSchema, JiraCredentialsSchema, LaminarCredentialsSchema, LinkedInAdsCredentialsSchema, @@ -915,6 +916,34 @@ export const SOURCE_PROVIDER_REGISTRY = { }, }, }, + jam: { + label: "Jam", + credentialSchema: JamCredentialsSchema, + credentialType: "jam", + connectable: true, + analysisSource: true, + queryInterface: false, + sourceApiInterface: true, + testable: true, + dashboardConnectable: true, + dashboardCredentialForm: "jam", + publicCategory: "Developer workflow", + guide: { + summary: + "Connect a Jam workspace through Jam MCP with a read-only personal access token.", + steps: [ + "Open Jam workspace Settings, choose MCP, and create a personal access token for this connection.", + "Grant only the `mcp:read` scope so OneQuery can inspect Jams without moving them or adding comments.", + "Copy the token immediately and store it in `credentials.accessToken`; Jam does not show the token again after creation.", + ], + exampleInput: { + sourceKey: "jam_workspace", + credentials: { + accessToken: "jam_pat_...", + }, + }, + }, + }, amazon_ads: { label: "Amazon Ads", credentialSchema: AmazonAdsCredentialsSchema, diff --git a/packages/server/package.json b/packages/server/package.json index f3817835..d35aabd0 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -52,6 +52,7 @@ "@bufbuild/protovalidate": "catalog:", "@hono/structured-logger": "catalog:", "@hono/zod-validator": "catalog:", + "@modelcontextprotocol/sdk": "catalog:", "@noble/ciphers": "2.1.1", "@onequery/audit-contracts": "workspace:*", "@onequery/base": "workspace:*", diff --git a/packages/server/src/services/data-source-tester.ts b/packages/server/src/services/data-source-tester.ts index 751997b9..0566eeed 100644 --- a/packages/server/src/services/data-source-tester.ts +++ b/packages/server/src/services/data-source-tester.ts @@ -18,6 +18,7 @@ import { testAmplitudeConnection } from "./testers/amplitude-tester"; import { DEFAULT_CONNECTION_TEST_TIMEOUT_SECONDS } from "./testers/defaults"; import { testGoogleAnalyticsConnection } from "./testers/ga-tester"; import { testGitHubConnection } from "./testers/github-tester"; +import { testJamConnection } from "./testers/jam-tester"; import { testMixpanelConnection } from "./testers/mixpanel-tester"; import { testPostHogConnection } from "./testers/posthog-tester"; import { testSentryConnection } from "./testers/sentry-tester"; @@ -49,6 +50,11 @@ const DIRECT_CONNECTION_TESTERS: Partial< testGitHubConnection( credentials as Extract ), + jam: async (credentials, options): Promise => + testJamConnection( + credentials as Extract, + options.timeoutSeconds + ), mixpanel: async (credentials, options): Promise => testMixpanelConnection( credentials as Extract, diff --git a/packages/server/src/services/jam-mcp-client.ts b/packages/server/src/services/jam-mcp-client.ts new file mode 100644 index 00000000..06d33397 --- /dev/null +++ b/packages/server/src/services/jam-mcp-client.ts @@ -0,0 +1,99 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +export const JAM_MCP_ENDPOINT = "https://mcp.jam.dev/mcp"; + +export type JamMcpTool = { + description?: string; + inputSchema: Record; + name: string; + title?: string; +}; + +export type JamMcpToolCallResult = { + content: unknown[]; + isError?: boolean; + structuredContent?: unknown; +}; + +export type JamMcpClient = { + callTool(input: { + arguments?: Record; + name: string; + }): Promise; + close(): Promise; + listTools(): Promise<{ tools: JamMcpTool[] }>; +}; + +export async function createJamMcpClient( + accessToken: string, + timeoutMs = 30_000 +): Promise { + const transport = new StreamableHTTPClientTransport( + new URL(JAM_MCP_ENDPOINT), + { + requestInit: { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + signal: AbortSignal.timeout(timeoutMs), + }, + } + ); + const client = new Client({ + name: "onequery-jam-provider", + version: "1.0.0", + }); + + try { + await client.connect(transport); + } catch (error) { + await transport.close().catch(() => undefined); + throw sanitizeJamMcpError(error, accessToken); + } + + return { + async callTool(input) { + try { + const result = await client.callTool(input); + if (!("content" in result) || !Array.isArray(result.content)) { + throw new Error("Jam MCP returned an unsupported task result"); + } + return { + content: result.content, + ...(typeof result.isError === "boolean" + ? { isError: result.isError } + : {}), + ...(result.structuredContent && + typeof result.structuredContent === "object" && + !Array.isArray(result.structuredContent) + ? { structuredContent: result.structuredContent } + : {}), + }; + } catch (error) { + throw sanitizeJamMcpError(error, accessToken); + } + }, + close: () => client.close(), + async listTools() { + try { + const result = await client.listTools(); + return { + tools: result.tools.map((tool) => ({ + ...(tool.description ? { description: tool.description } : {}), + inputSchema: tool.inputSchema, + name: tool.name, + ...(tool.title ? { title: tool.title } : {}), + })), + }; + } catch (error) { + throw sanitizeJamMcpError(error, accessToken); + } + }, + }; +} + +function sanitizeJamMcpError(error: unknown, accessToken: string): Error { + const message = error instanceof Error ? error.message : String(error); + return new Error(message.split(accessToken).join("***")); +} diff --git a/packages/server/src/services/testers/jam-tester.test.ts b/packages/server/src/services/testers/jam-tester.test.ts new file mode 100644 index 00000000..70195d69 --- /dev/null +++ b/packages/server/src/services/testers/jam-tester.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { JamMcpClient } from "../jam-mcp-client"; +import { testJamConnection } from "./jam-tester"; + +function createClient(toolNames: string[]): JamMcpClient { + return { + callTool: vi.fn(), + close: vi.fn(async () => undefined), + listTools: vi.fn(async () => ({ + tools: toolNames.map((name) => ({ + inputSchema: { type: "object" }, + name, + })), + })), + }; +} + +describe("testJamConnection", () => { + it("accepts a workspace exposing the required read tools", async () => { + const client = createClient(["getDetails", "listJams"]); + + const outcome = await testJamConnection( + { accessToken: "jam_pat_secret", type: "jam" }, + 5, + vi.fn(async () => client) + ); + + expect(outcome.isOk()).toBe(true); + expect(client.close).toHaveBeenCalledOnce(); + }); + + it("rejects a connection missing a core Jam tool", async () => { + const client = createClient(["getDetails"]); + + const outcome = await testJamConnection( + { accessToken: "jam_pat_secret", type: "jam" }, + 5, + vi.fn(async () => client) + ); + + expect(outcome.isErr()).toBe(true); + if (outcome.isErr()) { + expect(outcome.error.detail).toContain("listJams"); + } + expect(client.close).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/server/src/services/testers/jam-tester.ts b/packages/server/src/services/testers/jam-tester.ts new file mode 100644 index 00000000..18f915fc --- /dev/null +++ b/packages/server/src/services/testers/jam-tester.ts @@ -0,0 +1,58 @@ +import type { JamCredentials } from "@onequery/db/server"; +import { Result } from "better-result"; + +import { + createFailedConnectionTest, + createSuccessfulConnectionTest, +} from "../data-source-query/core/connection-test"; +import type { ConnectionTestOutcome } from "../data-source-query/core/connection-test"; +import { createJamMcpClient } from "../jam-mcp-client"; +import type { JamMcpClient } from "../jam-mcp-client"; +import { DEFAULT_CONNECTION_TEST_TIMEOUT_SECONDS } from "./defaults"; + +type CreateJamMcpClient = ( + accessToken: string, + timeoutMs?: number +) => Promise; + +const REQUIRED_JAM_TOOLS = ["getDetails", "listJams"] as const; + +export async function testJamConnection( + credentials: JamCredentials, + timeoutSeconds = DEFAULT_CONNECTION_TEST_TIMEOUT_SECONDS, + createClient: CreateJamMcpClient = createJamMcpClient +): Promise { + const startedAt = Date.now(); + let client: JamMcpClient | undefined; + + try { + client = await createClient( + credentials.accessToken, + Math.max(1, timeoutSeconds) * 1000 + ); + const { tools } = await client.listTools(); + const toolNames = new Set(tools.map((tool) => tool.name)); + const missingTools = REQUIRED_JAM_TOOLS.filter( + (toolName) => !toolNames.has(toolName) + ); + if (missingTools.length > 0) { + return Result.err( + createFailedConnectionTest({ + detail: `Jam MCP did not expose required tools: ${missingTools.join(", ")}`, + latencyMs: Date.now() - startedAt, + }) + ); + } + + return Result.ok(createSuccessfulConnectionTest(Date.now() - startedAt)); + } catch (error) { + return Result.err( + createFailedConnectionTest({ + detail: error instanceof Error ? error.message : String(error), + latencyMs: Date.now() - startedAt, + }) + ); + } finally { + await client?.close().catch(() => undefined); + } +} diff --git a/packages/server/src/source-api/adapters/jam-source-api-contract.ts b/packages/server/src/source-api/adapters/jam-source-api-contract.ts new file mode 100644 index 00000000..23f077f0 --- /dev/null +++ b/packages/server/src/source-api/adapters/jam-source-api-contract.ts @@ -0,0 +1,176 @@ +import type { JsonObject, JsonValue } from "@bufbuild/protobuf"; +import { z } from "zod"; + +import type { JamMcpClient, JamMcpTool } from "../../services/jam-mcp-client"; +import { + SourceApiInvalidRequestError, + SourceApiUnsupportedOperationError, +} from "../errors"; +import type { + PreparedSourceConnection, + SourceApiAdapter, + SourceApiDescriptor, + SourceApiExecutionResult, + SourceApiOperation, +} from "../types"; + +const JAM_READ_TOOL_NAMES = [ + "analyzeVideo", + "getConsoleLogs", + "getDetails", + "getMetadata", + "getNetworkRequests", + "getUserEvents", + "getVideoTranscript", + "listFolders", + "listJams", + "listMembers", +] as const; +const jamReadToolNames = new Set(JAM_READ_TOOL_NAMES); +const jsonValueSchema = z.json(); + +class JamInvalidRequestError extends SourceApiInvalidRequestError {} + +function createOperation(tool: JamMcpTool): SourceApiOperation { + return { + description: tool.description ?? `Call Jam MCP tool ${tool.name}.`, + examples: [], + fieldPolicy: { + acceptsInput: true, + allowsRawFields: true, + allowsTypedFields: true, + inputMode: "request_object", + mergePatches: true, + supportsArrayPaths: true, + supportsNestedPaths: true, + }, + headerPolicy: { + allowedRequestHeaders: [], + allowedResponseHeaders: ["content-type"], + }, + kind: "structured_request", + methodPolicy: { allowedMethods: ["POST"], defaultMethod: "POST" }, + name: tool.name, + notes: [`Input schema: ${JSON.stringify(tool.inputSchema)}`], + paginationPolicy: "none", + selectorKind: "none", + summary: tool.title ?? tool.description ?? `Call ${tool.name}.`, + }; +} + +function normalize( + descriptor: SourceApiDescriptor, + request: Parameters[0]["request"], + source: PreparedSourceConnection +) { + const operation = descriptor.operations.find( + (candidate) => candidate.name === request.operation.trim() + ); + if (!operation || !jamReadToolNames.has(operation.name)) { + throw new SourceApiUnsupportedOperationError(request.operation); + } + if (request.selector?.trim() || request.methodOverride?.trim()) { + throw new JamInvalidRequestError( + "Jam MCP operations do not accept selectors or method overrides" + ); + } + if (request.headers.length > 0 || request.body.kind !== "none") { + throw new JamInvalidRequestError( + "Jam MCP operations accept field patches only" + ); + } + + return { + body: { kind: "none" } as const, + descriptorVersion: descriptor.descriptorVersion, + headers: [], + kind: "structured_request" as const, + method: "POST", + operation: operation.name, + paginationPolicy: "none" as const, + provider: source.provider, + request: { + arguments: request.fieldPatch ?? {}, + toolName: operation.name, + }, + selectorTemplate: "mcp/tools/{toolName}", + sourceId: source.id, + sourceKey: source.sourceKey, + }; +} + +function readPrepared(request: JsonObject): { + arguments: JsonObject; + name: string; +} { + const toolName = request.toolName; + const args = request.arguments; + if (typeof toolName !== "string" || !jamReadToolNames.has(toolName)) { + throw new JamInvalidRequestError( + "Prepared Jam request has no allowed tool" + ); + } + if (!args || typeof args !== "object" || Array.isArray(args)) { + throw new JamInvalidRequestError( + "Prepared Jam request has invalid arguments" + ); + } + return { arguments: args, name: toolName }; +} + +function buildExecutionResult(input: { + operation: string; + result: Awaited>; + source: PreparedSourceConnection; +}): SourceApiExecutionResult { + const value = toJsonValue({ + content: input.result.content, + ...(input.result.structuredContent + ? { structuredContent: input.result.structuredContent } + : {}), + }); + return { + body: { kind: "json", value }, + contentType: "application/json", + headers: [{ name: "content-type", value: "application/json" }], + operation: input.operation, + source: { + displayName: input.source.displayName, + provider: input.source.provider, + sourceKey: input.source.sourceKey, + }, + status: 200, + }; +} + +function toJsonValue(value: unknown): JsonValue { + return jsonValueSchema.parse(JSON.parse(JSON.stringify(value))); +} + +function readToolError(content: unknown): string { + if (Array.isArray(content)) { + const text = content.find( + (item) => + item && + typeof item === "object" && + "type" in item && + item.type === "text" && + "text" in item && + typeof item.text === "string" + ); + if (text && typeof text === "object" && "text" in text) { + return String(text.text); + } + } + return "Jam MCP tool call failed"; +} + +export const jamSourceApiContract = { + buildExecutionResult, + createOperation, + descriptorVersion: "jam.mcp.v1", + isReadTool: (tool: JamMcpTool) => jamReadToolNames.has(tool.name), + normalize, + readPrepared, + readToolError, +}; diff --git a/packages/server/src/source-api/adapters/jam.test.ts b/packages/server/src/source-api/adapters/jam.test.ts new file mode 100644 index 00000000..4c0b7050 --- /dev/null +++ b/packages/server/src/source-api/adapters/jam.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { JamMcpClient } from "../../services/jam-mcp-client"; +import { finalizePreparedSourceApi } from "../normalize"; +import { createJamSourceApiAdapter } from "./jam"; + +const actor = { + capabilities: ["source_api.describe", "source_api.execute"], + membershipRoles: ["owner"], + organizationId: "org-1", + organizationSlug: "acme", + userId: "user-1", +} as const; + +const source = { + credentials: { accessToken: "jam_pat_secret", type: "jam" }, + displayName: "Jam workspace", + id: "source-1", + provider: "jam", + sourceKey: "jam_workspace", +} as const; + +function createClient(): JamMcpClient { + return { + callTool: vi.fn(async ({ arguments: args, name }) => ({ + content: [{ text: JSON.stringify(args), type: "text" }], + isError: false, + structuredContent: { name }, + })), + close: vi.fn(async () => undefined), + listTools: vi.fn(async () => ({ + tools: [ + { + description: "List Jams visible to the workspace.", + inputSchema: { + properties: { limit: { type: "number" } }, + type: "object", + }, + name: "listJams", + }, + { + description: "Read console logs.", + inputSchema: { + properties: { jamUrl: { type: "string" } }, + required: ["jamUrl"], + type: "object", + }, + name: "getConsoleLogs", + }, + { + description: "Return screenshot bytes.", + inputSchema: { type: "object" }, + name: "getScreenshot", + }, + { + description: "Add a comment.", + inputSchema: { type: "object" }, + name: "createComment", + }, + ], + })), + }; +} + +describe("Jam Source API adapter", () => { + it("describes only approved read-only Jam tools", async () => { + const client = createClient(); + const adapter = createJamSourceApiAdapter({ + createClient: vi.fn(async () => client), + }); + + const descriptor = await adapter.describe({ actor, source }); + + expect(descriptor.operations.map((operation) => operation.name)).toEqual([ + "getConsoleLogs", + "listJams", + ]); + expect(descriptor.operations[0]?.notes.join("\n")).toContain("jamUrl"); + expect(client.close).toHaveBeenCalledOnce(); + }); + + it("forwards field patches as MCP tool arguments", async () => { + const client = createClient(); + const adapter = createJamSourceApiAdapter({ + createClient: vi.fn(async () => client), + }); + const descriptor = await adapter.describe({ actor, source }); + const prepared = await adapter.normalize({ + actor, + descriptor, + request: { + body: { kind: "none" }, + fieldPatch: { jamUrl: "https://jam.dev/c/abc", limit: 25 }, + headers: [], + operation: "getConsoleLogs", + }, + source, + }); + + expect(prepared).toMatchObject({ + kind: "structured_request", + method: "POST", + operation: "getConsoleLogs", + request: { + arguments: { jamUrl: "https://jam.dev/c/abc", limit: 25 }, + toolName: "getConsoleLogs", + }, + }); + }); + + it("executes the prepared MCP tool call and closes the client", async () => { + const client = createClient(); + const adapter = createJamSourceApiAdapter({ + createClient: vi.fn(async () => client), + }); + const descriptor = await adapter.describe({ actor, source }); + const prepared = await adapter.normalize({ + actor, + descriptor, + request: { + body: { kind: "none" }, + fieldPatch: { query: "checkout" }, + headers: [], + operation: "listJams", + }, + source, + }); + + const result = await adapter.execute({ + actor, + prepared: finalizePreparedSourceApi(prepared), + source, + }); + + expect(client.callTool).toHaveBeenCalledWith({ + arguments: { query: "checkout" }, + name: "listJams", + }); + expect(result.body).toMatchObject({ + kind: "json", + value: { structuredContent: { name: "listJams" } }, + }); + expect(client.close).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/server/src/source-api/adapters/jam.ts b/packages/server/src/source-api/adapters/jam.ts new file mode 100644 index 00000000..d277d546 --- /dev/null +++ b/packages/server/src/source-api/adapters/jam.ts @@ -0,0 +1,95 @@ +import type { JamCredentials } from "@onequery/db/server"; + +import { createJamMcpClient } from "../../services/jam-mcp-client"; +import type { JamMcpClient } from "../../services/jam-mcp-client"; +import type { PreparedSourceConnection, SourceApiAdapter } from "../types"; +import { jamSourceApiContract } from "./jam-source-api-contract"; + +type CreateJamMcpClient = (accessToken: string) => Promise; + +export function createJamSourceApiAdapter( + dependencies: { createClient?: CreateJamMcpClient } = {} +): SourceApiAdapter { + const createClient = dependencies.createClient ?? createJamMcpClient; + + return { + provider: "jam", + async describe({ source }) { + const credentials = requireJamCredentials(source); + const result = await withJamMcpClient( + createClient, + credentials, + (client) => client.listTools() + ); + const operations = result.tools + .filter(jamSourceApiContract.isReadTool) + .toSorted((left, right) => left.name.localeCompare(right.name)) + .map(jamSourceApiContract.createOperation); + + return { + descriptorVersion: jamSourceApiContract.descriptorVersion, + examples: [], + notes: [ + "Jam operations run through the official Jam MCP endpoint using the saved workspace token.", + "Only approved read-only tools are exposed. Comment creation, Jam updates, and screenshot binary retrieval are unavailable.", + "Pass MCP tool arguments as field patches using the input schema shown on each operation.", + ], + operations, + source: { + displayName: source.displayName, + provider: source.provider, + sourceKey: source.sourceKey, + }, + }; + }, + async normalize({ descriptor, request, source }) { + return jamSourceApiContract.normalize(descriptor, request, source); + }, + async execute({ prepared, source }) { + if (prepared.kind !== "structured_request") { + throw new Error( + `Jam operation "${prepared.operation}" requires a structured plan` + ); + } + const credentials = requireJamCredentials(source); + const request = jamSourceApiContract.readPrepared(prepared.request); + const result = await withJamMcpClient( + createClient, + credentials, + (client) => client.callTool(request) + ); + if (result.isError) { + throw new Error(jamSourceApiContract.readToolError(result.content)); + } + return jamSourceApiContract.buildExecutionResult({ + operation: prepared.operation, + result, + source, + }); + }, + }; +} + +export const jamSourceApiAdapter = createJamSourceApiAdapter(); + +function requireJamCredentials( + source: PreparedSourceConnection +): JamCredentials { + if (source.provider === "jam" && source.credentials.type === "jam") { + return source.credentials; + } + throw new Error("Jam source credentials are invalid"); +} + +async function withJamMcpClient( + createClient: CreateJamMcpClient, + credentials: JamCredentials, + execute: (client: JamMcpClient) => Promise +): Promise { + const client = await createClient(credentials.accessToken); + try { + return await execute(client); + } finally { + await client.close().catch(() => undefined); + } +} diff --git a/packages/server/src/source-api/registry.ts b/packages/server/src/source-api/registry.ts index 655eeab4..c18ea62c 100644 --- a/packages/server/src/source-api/registry.ts +++ b/packages/server/src/source-api/registry.ts @@ -16,6 +16,7 @@ import { githubSourceApiAdapter } from "./adapters/github"; import { googleSearchConsoleSourceApiAdapter } from "./adapters/google-search-console"; import { granolaSourceApiAdapter } from "./adapters/granola"; import { hermesSourceApiAdapter } from "./adapters/hermes"; +import { jamSourceApiAdapter } from "./adapters/jam"; import { jiraSourceApiAdapter } from "./adapters/jira"; import { linearSourceApiAdapter } from "./adapters/linear"; import { linkedInAdsSourceApiAdapter } from "./adapters/linkedin-ads"; @@ -91,6 +92,7 @@ export const sourceApiRegistry = createSourceApiRegistry([ granolaSourceApiAdapter, hermesSourceApiAdapter, jiraSourceApiAdapter, + jamSourceApiAdapter, linearSourceApiAdapter, linkedInAdsSourceApiAdapter, microsoftClaritySourceApiAdapter, diff --git a/packages/ui/src/data-sources/provider-icons.tsx b/packages/ui/src/data-sources/provider-icons.tsx index 4905167b..07b6e0cb 100644 --- a/packages/ui/src/data-sources/provider-icons.tsx +++ b/packages/ui/src/data-sources/provider-icons.tsx @@ -3,6 +3,7 @@ import { IconDatabase, IconHelpCircle, IconTerminal2, + IconVideo, } from "@tabler/icons-react"; import type { ComponentType } from "react"; import { @@ -225,6 +226,10 @@ function E2BIcon(props: ProviderIconProps) { ); } +function JamIcon(props: ProviderIconProps) { + return ; +} + function MicrosoftClarityIcon({ size = 24, ...props }: ProviderIconProps) { return ( Date: Mon, 20 Jul 2026 19:19:34 -0700 Subject: [PATCH 2/2] fix(data-sources): complete Jam provider coverage --- apps/landing/src/shared/icons/brands.tsx | 21 +++++++++++++++++++ .../__snapshots__/credentials.test.ts.snap | 1 + 2 files changed, 22 insertions(+) diff --git a/apps/landing/src/shared/icons/brands.tsx b/apps/landing/src/shared/icons/brands.tsx index 8d8fa1de..6eee4815 100644 --- a/apps/landing/src/shared/icons/brands.tsx +++ b/apps/landing/src/shared/icons/brands.tsx @@ -139,6 +139,26 @@ function HermesIcon({ size, ...props }: IconSvgProps) { return ; } +function JamIcon({ size, ...props }: IconSvgProps) { + return ( + + + + ); +} + function CodexAppServerApiIcon(props: IconSvgProps) { return ; } @@ -253,6 +273,7 @@ const BRAND_ICONS = { granola: GranolaIcon, hermes: HermesIcon, homebrew: createSimpleBrandIcon(siHomebrew), + jam: JamIcon, jira: createSimpleBrandIcon(siJira), laminar: LaminarIcon, linear: createSimpleBrandIcon(siLinear), diff --git a/packages/db/src/__snapshots__/credentials.test.ts.snap b/packages/db/src/__snapshots__/credentials.test.ts.snap index e73d65d4..0a9a81ba 100644 --- a/packages/db/src/__snapshots__/credentials.test.ts.snap +++ b/packages/db/src/__snapshots__/credentials.test.ts.snap @@ -21,6 +21,7 @@ exports[`credentials schemas > credentialSchemaMap > matches supported provider "google_search_console", "granola", "hermes", + "jam", "jira", "laminar", "linear",