@apideck/mcp-connect 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,51 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@apideck/mcp-connect` are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-07-20
9
+
10
+ Initial release. Extracted as a standalone, framework-agnostic package from the
11
+ in-repo MCP-Connect core.
12
+
13
+ ### Added
14
+
15
+ - **Toolset engine** (`toolset.ts`): `buildToolset`, `loadMcpToolset`,
16
+ `dispatchToolCall`, `safeToolName`, `normalizeSchema`,
17
+ `renderMcpSystemPromptHint`, and the recoverable `McpConfigError`. Loads
18
+ connected MCP servers, namespaces tools as `slug__tool` (charset-safe, ≤64
19
+ chars with a stable hash suffix to avoid truncation collisions), and routes
20
+ inbound tool calls back to the right server.
21
+ - **Persistence seam** (`connection-store.ts`): the `ConnectionStore` interface
22
+ plus `Identity` and `StoredConnection` types. Tenancy and the
23
+ workspace-vs-per-user visibility rule are the host's responsibility.
24
+ - **In-memory reference store** (`connection-store-memory.ts`):
25
+ `InMemoryConnectionStore` — a zero-dependency implementation that enforces the
26
+ full visibility contract, so the package runs standalone.
27
+ - **MCP transport client** (`client.ts`): `mcpListTools`, `mcpCallTool`, and
28
+ `McpRpcError`. Thin shim over `@modelcontextprotocol/sdk` that tries Streamable
29
+ HTTP first and falls back to legacy HTTP+SSE, with an end-to-end request
30
+ timeout.
31
+ - **OAuth provider plugin registry** (`oauth-provider.ts`):
32
+ `registerOAuthProvider`, `getOAuthProvider`, `registeredOAuthProviderSlugs`,
33
+ and the `OAuthProvider` / `OAuthTokenResult` types. Connect flows resolve
34
+ providers by slug instead of branching on vendor literals.
35
+ - **AES-256-GCM token encryption** (`crypto.ts`, new in this package):
36
+ `encryptToken`, `decryptToken`, `generateKey`, `deriveKeyFromPassphrase`, and
37
+ `safeEqual`. Authenticated encryption-at-rest under an injected KEK — the
38
+ recommended default. Detects tampering and wrong keys on decrypt.
39
+ - **Legacy obfuscation** (`obfuscate.ts`): `obfuscateToken` /
40
+ `deobfuscateToken` (HMAC-XOR). Preserved for back-compat only and explicitly
41
+ documented as **not** encryption.
42
+ - **Neutral types** (`types.ts`): `ToolDef`, `ToolInputSchema`,
43
+ `ServerCatalogEntry`.
44
+ - **Runnable example** (`examples/quickstart.ts`): stands up a throwaway
45
+ localhost MCP server and exercises encrypt → store → load → namespace →
46
+ dispatch with zero external dependencies.
47
+ - Docs: `README.md` (quickstart, `ConnectionStore` contract, provider-plugin
48
+ authoring guide, honest token-storage security section), `ARCHITECTURE.md`,
49
+ `CONTRIBUTING.md`, MIT `LICENSE`.
50
+
51
+ [0.1.0]: https://github.com/apideck-libraries/mcp-connect/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Apideck
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,277 @@
1
+ # @apideck/mcp-connect
2
+
3
+ Framework-agnostic core for **connecting, namespacing, and dispatching
4
+ [Model Context Protocol](https://modelcontextprotocol.io) (MCP) tools** — with
5
+ pluggable persistence, OAuth providers, and token encryption.
6
+
7
+ It is the reusable heart of a "connect your own MCP servers" feature: given a
8
+ set of connected MCP servers, it loads their tools, namespaces them so their
9
+ names never collide, exposes them as neutral tool definitions you can hand to
10
+ any LLM, and routes inbound tool calls back to the right server. It has **no
11
+ database, no web framework, and no LLM-vendor dependency** — everything
12
+ app-specific is injected through small seams.
13
+
14
+ ```
15
+ connected servers ──► load + namespace tools ──► neutral ToolDef[] ──► your LLM
16
+ ▲ │
17
+ │ tool call by name
18
+ ConnectionStore │
19
+ (you implement) ◄──── dispatch routes it back to the right server ◄───┘
20
+ ```
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ npm install @apideck/mcp-connect
26
+ # peer runtime dep is bundled: @modelcontextprotocol/sdk
27
+ ```
28
+
29
+ Requires Node.js >= 20. ESM only (`"type": "module"`).
30
+
31
+ ## Quickstart
32
+
33
+ A complete, runnable version of this (with a throwaway localhost MCP server so
34
+ the dispatch is real) lives in [`examples/quickstart.ts`](./examples/quickstart.ts)
35
+ — run it with `npm run example`.
36
+
37
+ ```ts
38
+ import {
39
+ InMemoryConnectionStore,
40
+ loadMcpToolset,
41
+ dispatchToolCall,
42
+ renderMcpSystemPromptHint,
43
+ encryptToken,
44
+ decryptToken,
45
+ generateKey,
46
+ type Identity,
47
+ } from "@apideck/mcp-connect";
48
+
49
+ // 1. Resolve a 32-byte key-encryption key (KEK). In production this comes from
50
+ // your secret manager; never hard-code it.
51
+ const kek = generateKey();
52
+
53
+ // 2. Persist a connection. The store only ever sees the ENCRYPTED token.
54
+ const store = new InMemoryConnectionStore();
55
+ store.addConnection({
56
+ tenantId: "acme-inc",
57
+ serverSlug: "front",
58
+ serverName: "Front",
59
+ mcpEndpoint: "https://mcp.frontapp.com/mcp",
60
+ accessTokenObfuscated: encryptToken("the-oauth-access-token", kek),
61
+ scope: "workspace",
62
+ toolCatalog: { tools: [/* fetched at connect time via mcpListTools */] },
63
+ });
64
+
65
+ // 3. Load + namespace the tools for a given identity. The core decrypts via the
66
+ // injected function — the store never does crypto.
67
+ const identity: Identity = { tenantId: "acme-inc", ownerId: "user-1" };
68
+ const toolset = await loadMcpToolset(store, identity, (enc) => decryptToken(enc, kek));
69
+
70
+ // 4. Hand toolset.toolDefs to your LLM, and optionally add a prompt hint.
71
+ const systemHint = renderMcpSystemPromptHint(toolset);
72
+
73
+ // 5. When the model calls a tool, route it back:
74
+ const result = await dispatchToolCall(
75
+ toolset,
76
+ "front__search", // namespaced name the model called
77
+ { query: "acme.com" }, // tool input
78
+ { resolveHeaders: () => ({}) },
79
+ );
80
+ // result.text is ready to drop into a tool_result block.
81
+ ```
82
+
83
+ ## Concepts
84
+
85
+ ### Tool namespacing
86
+
87
+ MCP servers don't namespace their tool names, so two servers could both expose a
88
+ `search` tool. `mcp-connect` prefixes every tool with its server slug and a
89
+ double underscore: `front__search`. Names are sanitized to `[A-Za-z0-9_-]{1,64}`
90
+ (the Anthropic constraint, a safe lowest common denominator) and, when a name
91
+ would exceed 64 chars, truncated with a stable hash suffix so distinct tools
92
+ never collide. That same `slug__tool` convention is what lets `dispatchToolCall`
93
+ route an inbound call back to the right server with no extra round trip.
94
+
95
+ ### The `ConnectionStore` seam
96
+
97
+ Persistence is injected. The core only ever calls:
98
+
99
+ ```ts
100
+ interface ConnectionStore {
101
+ listConnections(identity: Identity): Promise<StoredConnection[]>;
102
+ }
103
+
104
+ interface Identity {
105
+ tenantId: string; // opaque routing key
106
+ ownerId: string; // the signed-in user (scopes per-user connections)
107
+ }
108
+ ```
109
+
110
+ Your implementation must honour the **visibility contract**:
111
+
112
+ - a connection is only visible within its own `tenantId`;
113
+ - `workspace`-scope connections are visible to every user in the tenant;
114
+ - `per_user`-scope connections are visible only to their `userId` owner.
115
+
116
+ `InMemoryConnectionStore` ships in the box (great for tests, prototypes, and the
117
+ example) and demonstrates exactly that contract. For production, implement the
118
+ interface against your own datastore (SQL, KV, an ORM, …). `StoredConnection`
119
+ carries no `tenantId` field because tenant scoping is the store's job, not the
120
+ core's — your store filters by tenant before returning rows.
121
+
122
+ `StoredConnection` fields:
123
+
124
+ | field | meaning |
125
+ | --- | --- |
126
+ | `connectionId` | your primary key |
127
+ | `accessTokenObfuscated` | the token **as persisted** (obfuscated or encrypted — the core decrypts via the injected function) |
128
+ | `toolCatalog` | cached `{ tools: [...] }` fetched at connect time |
129
+ | `scope` | `"workspace"` or `"per_user"` |
130
+ | `userId` | owner for per-user connections, else `null` |
131
+ | `serverId` / `serverSlug` / `serverName` / `mcpEndpoint` | the joined server metadata |
132
+
133
+ ## Provider-plugin authoring guide (OAuth)
134
+
135
+ OAuth-backed servers connect through a small **provider plugin**. Instead of
136
+ branching on vendor slugs in your connect routes, you register an `OAuthProvider`
137
+ and resolve it by slug. Adding a new provider is a registration in your code —
138
+ never an edit to this package.
139
+
140
+ ```ts
141
+ import { registerOAuthProvider, getOAuthProvider, type OAuthProvider } from "@apideck/mcp-connect";
142
+
143
+ const frontProvider: OAuthProvider = {
144
+ slug: "front",
145
+
146
+ // Is the host config present? (Read your own env/secrets here — the core never does.)
147
+ isConfigured: () => Boolean(process.env.FRONT_CLIENT_ID && process.env.FRONT_CLIENT_SECRET),
148
+ notConfiguredMessage: "Set FRONT_CLIENT_ID and FRONT_CLIENT_SECRET.",
149
+
150
+ // Where the vendor should redirect back to. Derive from the request origin
151
+ // (optionally honouring an env override).
152
+ deriveRedirectUri: (origin) => `${origin}/api/integrations/front/oauth/callback`,
153
+
154
+ // The vendor authorize URL you redirect the user to.
155
+ buildAuthorizeUrl: ({ redirectUri, state, scope }) => {
156
+ const u = new URL("https://app.frontapp.com/oauth/authorize");
157
+ u.searchParams.set("client_id", process.env.FRONT_CLIENT_ID!);
158
+ u.searchParams.set("redirect_uri", redirectUri);
159
+ u.searchParams.set("response_type", "code");
160
+ u.searchParams.set("state", state);
161
+ return u.toString();
162
+ },
163
+
164
+ // Exchange the authorization code for tokens, returned in the neutral shape.
165
+ exchangeCode: async ({ code, redirectUri }) => {
166
+ const res = await fetch("https://app.frontapp.com/oauth/token", {
167
+ method: "POST",
168
+ headers: { "content-type": "application/json" },
169
+ body: JSON.stringify({
170
+ grant_type: "authorization_code",
171
+ code,
172
+ redirect_uri: redirectUri,
173
+ client_id: process.env.FRONT_CLIENT_ID,
174
+ client_secret: process.env.FRONT_CLIENT_SECRET,
175
+ }),
176
+ });
177
+ const json = await res.json();
178
+ return {
179
+ accessToken: json.access_token,
180
+ refreshToken: json.refresh_token ?? null,
181
+ tokenType: json.token_type ?? "Bearer",
182
+ expiresInSeconds: json.expires_in ?? null,
183
+ };
184
+ },
185
+
186
+ // Optional: refresh support.
187
+ refresh: async ({ refreshToken }) => { /* ... */ },
188
+ };
189
+
190
+ registerOAuthProvider(frontProvider);
191
+ ```
192
+
193
+ Your connect route then does the vendor-agnostic thing:
194
+
195
+ ```ts
196
+ const provider = getOAuthProvider(slug);
197
+ if (!provider) return respond(501, "Unknown provider");
198
+ if (!provider.isConfigured()) return respond(400, provider.notConfiguredMessage);
199
+ const redirectUri = provider.deriveRedirectUri(requestOrigin);
200
+ redirect(provider.buildAuthorizeUrl({ redirectUri, state, scope }));
201
+ // ...callback: const tokens = await provider.exchangeCode({ code, redirectUri });
202
+ // encrypt tokens.accessToken, fetch the tool catalog, store.addConnection(...)
203
+ ```
204
+
205
+ Registration is idempotent (last registration for a slug wins), so hot-reload
206
+ and repeated imports are safe.
207
+
208
+ ## Security: token storage
209
+
210
+ Access tokens are secrets. This package gives you **two** token-at-rest options.
211
+ Choose deliberately:
212
+
213
+ ### `crypto.ts` — AES-256-GCM (recommended)
214
+
215
+ Real authenticated encryption. `encryptToken` / `decryptToken` provide
216
+ confidentiality **and** tamper-detection: any modification to the ciphertext
217
+ (or the wrong key) makes `decryptToken` throw rather than return garbage.
218
+
219
+ ```ts
220
+ import { encryptToken, decryptToken, generateKey, deriveKeyFromPassphrase } from "@apideck/mcp-connect";
221
+
222
+ const kek = generateKey(); // 32-byte key → your secret manager
223
+ const envelope = encryptToken("secret-token", kek); // "v1.<iv>.<tag>.<ciphertext>"
224
+ const plain = decryptToken(envelope, kek);
225
+ ```
226
+
227
+ - The key (a **KEK**, key-encryption key) is injected as a 32-byte `Buffer`. The
228
+ package never reads env — your host resolves the key (KMS, secret manager, env
229
+ var) and passes it in, so **key rotation is entirely under your control**.
230
+ - `deriveKeyFromPassphrase(passphrase, salt)` derives a KEK via scrypt for simple
231
+ single-operator setups. Store and reuse the salt. Prefer `generateKey()` + a
232
+ secret manager for production.
233
+ - **Use this as your default.**
234
+
235
+ ### `obfuscate.ts` — HMAC-XOR obfuscation (NOT encryption)
236
+
237
+ `obfuscateToken` / `deobfuscateToken` implement an HMAC-SHA256 keystream XOR.
238
+
239
+ > **This is obfuscation, not encryption. It is NOT secure and must never be
240
+ > described as encryption-at-rest.** It only stops tokens from appearing in
241
+ > plaintext to a casual `cat`, log dump, or generic secrets scanner. It provides
242
+ > **no integrity guarantee** and should not be relied on to protect against an
243
+ > attacker who reads your database.
244
+
245
+ It is kept **only** for back-compat with data written by earlier versions and
246
+ for environments that explicitly accept its (non-)guarantees. New integrations
247
+ should use AES-256-GCM.
248
+
249
+ Both modules share the same shape (`(value, key) → string` and back), so the
250
+ core's injected `deobfuscate` function works with either — you decide which by
251
+ choosing which decrypt function you pass to `loadMcpToolset` / `buildToolset`.
252
+
253
+ ## API surface
254
+
255
+ See [`ARCHITECTURE.md`](./ARCHITECTURE.md) for the module boundary and the full
256
+ export list. In short:
257
+
258
+ - **Persistence:** `ConnectionStore`, `Identity`, `StoredConnection`, `InMemoryConnectionStore`
259
+ - **Toolset:** `buildToolset`, `loadMcpToolset`, `dispatchToolCall`, `safeToolName`, `normalizeSchema`, `renderMcpSystemPromptHint`, `McpConfigError`
260
+ - **Transport:** `mcpListTools`, `mcpCallTool`, `McpRpcError`
261
+ - **OAuth plugins:** `registerOAuthProvider`, `getOAuthProvider`, `registeredOAuthProviderSlugs`, `OAuthProvider`
262
+ - **Token protection:** `encryptToken`, `decryptToken`, `generateKey`, `deriveKeyFromPassphrase`, `safeEqual` (AES-GCM); `obfuscateToken`, `deobfuscateToken` (legacy)
263
+
264
+ ## Development
265
+
266
+ ```bash
267
+ npm install
268
+ npm run build # tsc → dist/ with .d.ts declarations
269
+ npm test # vitest
270
+ npm run typecheck # tsc --noEmit (includes examples/)
271
+ npm run lint # eslint
272
+ npm run example # runs examples/quickstart.ts end-to-end
273
+ ```
274
+
275
+ ## License
276
+
277
+ MIT © Apideck. See [LICENSE](./LICENSE).
@@ -0,0 +1,35 @@
1
+ export interface McpToolDefinition {
2
+ name: string;
3
+ description?: string;
4
+ /** JSON Schema for the tool's input. */
5
+ inputSchema?: Record<string, unknown>;
6
+ }
7
+ export interface McpListToolsResponse {
8
+ tools: McpToolDefinition[];
9
+ }
10
+ export interface McpCallToolResult {
11
+ content: Array<{
12
+ type: string;
13
+ text?: string;
14
+ data?: string;
15
+ mimeType?: string;
16
+ }>;
17
+ isError?: boolean;
18
+ }
19
+ export interface McpClientOptions {
20
+ endpoint: string;
21
+ accessToken: string;
22
+ /** Optional additional headers (e.g. for API-key style auth that
23
+ * uses a non-standard header). */
24
+ extraHeaders?: Record<string, string>;
25
+ /** Soft timeout per RPC. Defaults to 30s. */
26
+ timeoutMs?: number;
27
+ }
28
+ declare class McpRpcError extends Error {
29
+ readonly code: number;
30
+ constructor(message: string, code: number);
31
+ }
32
+ export declare function mcpListTools(opts: McpClientOptions): Promise<McpListToolsResponse>;
33
+ export declare function mcpCallTool(opts: McpClientOptions, name: string, args?: Record<string, unknown>): Promise<McpCallToolResult>;
34
+ export { McpRpcError };
35
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AA2BA,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wCAAwC;IACxC,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACvC;AAED,MAAM,WAAW,oBAAoB;IACnC,KAAK,EAAE,iBAAiB,EAAE,CAAC;CAC5B;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAClF,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB;uCACmC;IACnC,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,6CAA6C;IAC7C,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,cAAM,WAAY,SAAQ,KAAK;aACgB,IAAI,EAAE,MAAM;gBAA7C,OAAO,EAAE,MAAM,EAAkB,IAAI,EAAE,MAAM;CAI1D;AAiGD,wBAAsB,YAAY,CAAC,IAAI,EAAE,gBAAgB,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAWxF;AAED,wBAAsB,WAAW,CAC/B,IAAI,EAAE,gBAAgB,EACtB,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GACjC,OAAO,CAAC,iBAAiB,CAAC,CAQ5B;AAED,OAAO,EAAE,WAAW,EAAE,CAAC"}
package/dist/client.js ADDED
@@ -0,0 +1,142 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Apideck
3
+ /**
4
+ * MCP client wrapper.
5
+ *
6
+ * Thin shim over @modelcontextprotocol/sdk so the rest of the app
7
+ * keeps speaking our small surface (mcpListTools / mcpCallTool) and
8
+ * doesn't see the SDK details. The SDK handles both transports
9
+ * transparently:
10
+ * - Legacy HTTP+SSE: GET <endpoint>/sse, server pushes a session-
11
+ * scoped POST URL via an `endpoint` event, client POSTs JSON-RPC
12
+ * there. (Front uses this.)
13
+ * - Streamable HTTP: single URL, POST gets the JSON-RPC response
14
+ * immediately or via a short-lived SSE stream.
15
+ * We try Streamable HTTP first; if the server speaks the legacy
16
+ * transport it'll reject and we fall back.
17
+ */
18
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
19
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
20
+ // SSEClientTransport is the legacy HTTP+SSE transport. The SDK marks
21
+ // it deprecated but still ships it because servers like Front haven't
22
+ // migrated to Streamable HTTP yet. Keep until that's no longer true.
23
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
24
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
25
+ class McpRpcError extends Error {
26
+ code;
27
+ constructor(message, code) {
28
+ super(message);
29
+ this.code = code;
30
+ this.name = "McpRpcError";
31
+ }
32
+ }
33
+ /**
34
+ * Build the auth + Accept headers every MCP request needs. The SDK
35
+ * accepts a `requestInit` that's spread into each fetch.
36
+ */
37
+ function buildHeaders(opts) {
38
+ return {
39
+ Authorization: `Bearer ${opts.accessToken}`,
40
+ ...(opts.extraHeaders ?? {}),
41
+ };
42
+ }
43
+ /**
44
+ * Connect using Streamable HTTP first, fall back to legacy SSE if the
45
+ * server doesn't speak the new transport. Returns a connected Client
46
+ * instance the caller is responsible for closing.
47
+ */
48
+ async function connect(opts) {
49
+ const client = new Client({ name: "@apideck/mcp-connect", version: "0.1.0" }, { capabilities: {} });
50
+ const url = new URL(opts.endpoint);
51
+ const headers = buildHeaders(opts);
52
+ // Try the newer transport first. If the server rejects with 4xx
53
+ // (typically 404 or 405), fall back to legacy SSE on the same URL.
54
+ try {
55
+ const transport = new StreamableHTTPClientTransport(url, {
56
+ requestInit: { headers },
57
+ });
58
+ await client.connect(transport);
59
+ return client;
60
+ }
61
+ catch (err) {
62
+ // Any non-success on Streamable HTTP → try legacy SSE. The SDK
63
+ // will throw various error shapes here (typed Error, status codes)
64
+ // so we just bail to the fallback rather than inspecting too hard.
65
+ // The SDK attaches `requestInit.headers` to BOTH the initial SSE
66
+ // GET and the follow-up POST requests (see SSEClientTransport
67
+ // _commonHeaders). We don't need to override eventSourceInit.fetch.
68
+ const sseTransport = new SSEClientTransport(url, {
69
+ requestInit: { headers },
70
+ });
71
+ try {
72
+ await client.connect(sseTransport);
73
+ return client;
74
+ }
75
+ catch (sseErr) {
76
+ const msg = sseErr instanceof Error ? sseErr.message : String(sseErr);
77
+ // Surface BOTH errors so callers see what we tried. The route
78
+ // pattern-matches McpRpcError to choose the user-facing message.
79
+ const status = /401|unauthor/i.test(msg) ? 401 :
80
+ /403|forbidden/i.test(msg) ? 403 :
81
+ /5\d\d/.test(msg) ? 502 :
82
+ -32603;
83
+ // Re-throw the original error so its prototype/name is preserved
84
+ // (e.g. AbortError / TimeoutError) wrapped as McpRpcError.
85
+ throw new McpRpcError(`${err instanceof Error ? err.message : String(err)} | sse fallback: ${msg}`, status);
86
+ }
87
+ }
88
+ }
89
+ async function withClient(opts, fn) {
90
+ const timeoutMs = opts.timeoutMs ?? 30_000;
91
+ // The race covers BOTH connect() and the inner call. Earlier
92
+ // versions only raced fn(client), which left a window where a
93
+ // hung SDK handshake (Streamable HTTP probe + SSE fallback) could
94
+ // block the request indefinitely before the timeout was armed.
95
+ // We share one timer between the two phases so the total budget
96
+ // is enforced end-to-end, not per phase.
97
+ let timeoutId = null;
98
+ const timeoutPromise = new Promise((_, reject) => {
99
+ timeoutId = setTimeout(() => reject(Object.assign(new Error("MCP request timed out"), { name: "TimeoutError" })), timeoutMs);
100
+ });
101
+ let client;
102
+ try {
103
+ client = await Promise.race([connect(opts), timeoutPromise]);
104
+ }
105
+ catch (err) {
106
+ if (timeoutId)
107
+ clearTimeout(timeoutId);
108
+ throw err;
109
+ }
110
+ try {
111
+ return await Promise.race([fn(client), timeoutPromise]);
112
+ }
113
+ finally {
114
+ if (timeoutId)
115
+ clearTimeout(timeoutId);
116
+ // Best-effort close; we don't care about errors during teardown.
117
+ void client.close().catch(() => undefined);
118
+ }
119
+ }
120
+ export async function mcpListTools(opts) {
121
+ return withClient(opts, async (client) => {
122
+ const res = await client.listTools();
123
+ return {
124
+ tools: res.tools.map((t) => ({
125
+ name: t.name,
126
+ description: t.description,
127
+ inputSchema: t.inputSchema,
128
+ })),
129
+ };
130
+ });
131
+ }
132
+ export async function mcpCallTool(opts, name, args = {}) {
133
+ return withClient(opts, async (client) => {
134
+ const res = await client.callTool({ name, arguments: args });
135
+ return {
136
+ content: (res.content ?? []),
137
+ isError: res.isError === true,
138
+ };
139
+ });
140
+ }
141
+ export { McpRpcError };
142
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,34 @@
1
+ import type { ConnectionStore, Identity, StoredConnection } from "./connection-store.js";
2
+ /** Input for {@link InMemoryConnectionStore.addConnection}. Only the essentials
3
+ * are required; ids default to random UUIDs and scope defaults to per_user. */
4
+ export interface AddConnectionInput {
5
+ tenantId: string;
6
+ serverSlug: string;
7
+ serverName: string;
8
+ mcpEndpoint: string;
9
+ /** Token as persisted — obfuscate (obfuscate.ts) or encrypt (crypto.ts)
10
+ * before passing it in. The store never sees plaintext by convention. */
11
+ accessTokenObfuscated: string;
12
+ /** Cached tool catalog, shaped `{ tools: [...] }`. Defaults to no tools. */
13
+ toolCatalog?: unknown;
14
+ scope?: "workspace" | "per_user";
15
+ /** Required when scope is per_user; ignored (forced null) for workspace. */
16
+ userId?: string | null;
17
+ connectionId?: string;
18
+ serverId?: string;
19
+ }
20
+ export declare class InMemoryConnectionStore implements ConnectionStore {
21
+ private records;
22
+ /** Register/save a connection (the "connect" step in a real flow, after the
23
+ * OAuth exchange + catalog fetch). Returns the stored row. */
24
+ addConnection(input: AddConnectionInput): StoredConnection;
25
+ /** Remove a connection by id. Returns true if a row was removed. */
26
+ removeConnection(connectionId: string): boolean;
27
+ /** Drop every connection (test/reset helper). */
28
+ clear(): void;
29
+ /** ConnectionStore contract: rows visible to `identity`, enforcing the tenant
30
+ * + scope visibility rule. */
31
+ listConnections(identity: Identity): Promise<StoredConnection[]>;
32
+ private strip;
33
+ }
34
+ //# sourceMappingURL=connection-store-memory.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"connection-store-memory.d.ts","sourceRoot":"","sources":["../src/connection-store-memory.ts"],"names":[],"mappings":"AAwBA,OAAO,KAAK,EAAE,eAAe,EAAE,QAAQ,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAOzF;gFACgF;AAChF,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB;8EAC0E;IAC1E,qBAAqB,EAAE,MAAM,CAAC;IAC9B,4EAA4E;IAC5E,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,KAAK,CAAC,EAAE,WAAW,GAAG,UAAU,CAAC;IACjC,4EAA4E;IAC5E,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,qBAAa,uBAAwB,YAAW,eAAe;IAC7D,OAAO,CAAC,OAAO,CAAsB;IAErC;mEAC+D;IAC/D,aAAa,CAAC,KAAK,EAAE,kBAAkB,GAAG,gBAAgB;IAqB1D,oEAAoE;IACpE,gBAAgB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO;IAM/C,iDAAiD;IACjD,KAAK,IAAI,IAAI;IAIb;mCAC+B;IACzB,eAAe,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;IAOtE,OAAO,CAAC,KAAK;CAMd"}
@@ -0,0 +1,72 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Apideck
3
+ /**
4
+ * InMemoryConnectionStore — a zero-dependency reference implementation of the
5
+ * `ConnectionStore` seam.
6
+ *
7
+ * It lets the package run standalone (examples, tests, prototypes) with no
8
+ * database. It is NOT persistent — everything lives in a process-local array
9
+ * and is lost on restart. Production hosts implement `ConnectionStore` against
10
+ * their own datastore (SQL, KV, an ORM, …); this class shows the exact
11
+ * visibility contract they must honour.
12
+ *
13
+ * Visibility rule (the same one a real store must enforce):
14
+ * - a connection is only visible within its own `tenantId`;
15
+ * - `workspace`-scope connections are visible to every user in the tenant;
16
+ * - `per_user`-scope connections are visible only to their `userId` owner.
17
+ *
18
+ * The store is pure persistence: it stores the token exactly as given
19
+ * (`accessTokenObfuscated`, already obfuscated or encrypted by the caller) and
20
+ * never performs crypto itself.
21
+ */
22
+ import { randomUUID } from "node:crypto";
23
+ export class InMemoryConnectionStore {
24
+ records = [];
25
+ /** Register/save a connection (the "connect" step in a real flow, after the
26
+ * OAuth exchange + catalog fetch). Returns the stored row. */
27
+ addConnection(input) {
28
+ const scope = input.scope ?? "per_user";
29
+ if (scope === "per_user" && !input.userId) {
30
+ throw new Error("per_user connections require a userId");
31
+ }
32
+ const record = {
33
+ tenantId: input.tenantId,
34
+ connectionId: input.connectionId ?? randomUUID(),
35
+ accessTokenObfuscated: input.accessTokenObfuscated,
36
+ toolCatalog: input.toolCatalog ?? { tools: [] },
37
+ scope,
38
+ userId: scope === "workspace" ? null : (input.userId ?? null),
39
+ serverId: input.serverId ?? randomUUID(),
40
+ serverSlug: input.serverSlug,
41
+ serverName: input.serverName,
42
+ mcpEndpoint: input.mcpEndpoint,
43
+ };
44
+ this.records.push(record);
45
+ return this.strip(record);
46
+ }
47
+ /** Remove a connection by id. Returns true if a row was removed. */
48
+ removeConnection(connectionId) {
49
+ const before = this.records.length;
50
+ this.records = this.records.filter((r) => r.connectionId !== connectionId);
51
+ return this.records.length < before;
52
+ }
53
+ /** Drop every connection (test/reset helper). */
54
+ clear() {
55
+ this.records = [];
56
+ }
57
+ /** ConnectionStore contract: rows visible to `identity`, enforcing the tenant
58
+ * + scope visibility rule. */
59
+ async listConnections(identity) {
60
+ return this.records
61
+ .filter((r) => r.tenantId === identity.tenantId)
62
+ .filter((r) => r.scope === "workspace" || r.userId === identity.ownerId)
63
+ .map((r) => this.strip(r));
64
+ }
65
+ strip(record) {
66
+ // Hand back the neutral StoredConnection shape (drop the internal tenantId).
67
+ const { tenantId: _tenantId, ...rest } = record;
68
+ void _tenantId;
69
+ return rest;
70
+ }
71
+ }
72
+ //# sourceMappingURL=connection-store-memory.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"connection-store-memory.js","sourceRoot":"","sources":["../src/connection-store-memory.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAC/B,6BAA6B;AAE7B;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AA2BzC,MAAM,OAAO,uBAAuB;IAC1B,OAAO,GAAmB,EAAE,CAAC;IAErC;mEAC+D;IAC/D,aAAa,CAAC,KAAyB;QACrC,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,IAAI,UAAU,CAAC;QACxC,IAAI,KAAK,KAAK,UAAU,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC;YAC1C,MAAM,IAAI,KAAK,CAAC,uCAAuC,CAAC,CAAC;QAC3D,CAAC;QACD,MAAM,MAAM,GAAiB;YAC3B,QAAQ,EAAE,KAAK,CAAC,QAAQ;YACxB,YAAY,EAAE,KAAK,CAAC,YAAY,IAAI,UAAU,EAAE;YAChD,qBAAqB,EAAE,KAAK,CAAC,qBAAqB;YAClD,WAAW,EAAE,KAAK,CAAC,WAAW,IAAI,EAAE,KAAK,EAAE,EAAE,EAAE;YAC/C,KAAK;YACL,MAAM,EAAE,KAAK,KAAK,WAAW,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,IAAI,IAAI,CAAC;YAC7D,QAAQ,EAAE,KAAK,CAAC,QAAQ,IAAI,UAAU,EAAE;YACxC,UAAU,EAAE,KAAK,CAAC,UAAU;YAC5B,UAAU,EAAE,KAAK,CAAC,UAAU;YAC5B,WAAW,EAAE,KAAK,CAAC,WAAW;SAC/B,CAAC;QACF,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QAC1B,OAAO,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;IAC5B,CAAC;IAED,oEAAoE;IACpE,gBAAgB,CAAC,YAAoB;QACnC,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC;QACnC,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,YAAY,KAAK,YAAY,CAAC,CAAC;QAC3E,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,GAAG,MAAM,CAAC;IACtC,CAAC;IAED,iDAAiD;IACjD,KAAK;QACH,IAAI,CAAC,OAAO,GAAG,EAAE,CAAC;IACpB,CAAC;IAED;mCAC+B;IAC/B,KAAK,CAAC,eAAe,CAAC,QAAkB;QACtC,OAAO,IAAI,CAAC,OAAO;aAChB,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,KAAK,QAAQ,CAAC,QAAQ,CAAC;aAC/C,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,KAAK,WAAW,IAAI,CAAC,CAAC,MAAM,KAAK,QAAQ,CAAC,OAAO,CAAC;aACvE,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;IAC/B,CAAC;IAEO,KAAK,CAAC,MAAoB;QAChC,6EAA6E;QAC7E,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,GAAG,IAAI,EAAE,GAAG,MAAM,CAAC;QAChD,KAAK,SAAS,CAAC;QACf,OAAO,IAAI,CAAC;IACd,CAAC;CACF"}