@hydradb/mcp 1.2.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The hand-rolled HTTP path for endpoints the SDK does not expose.
3
+ *
4
+ * CONTRACT §2 rule 7: a call that bypasses the SDK is still the wrapper — same
5
+ * `API-Version: 2` header, same envelope-by-shape unwrap, same translated error
6
+ * type — so a caller cannot tell which methods went through the SDK. This used
7
+ * to live inside GraphResource, where BYOG was the only such surface. The
8
+ * connected-subgraph read (PRO-1848) is the second, and a second copy of the
9
+ * retry/abort/translate logic is exactly what the rule exists to prevent, so
10
+ * it moved here and both resources call it.
11
+ *
12
+ * `fetchFn` is injectable for tests only; production never sets it.
13
+ */
14
+ import { unwrap } from "./envelope.js";
15
+ import { HydraWrapperError, responseError, translateError } from "./errors.js";
16
+ /** The SDK's own default, restated so this file does not depend on importing it. */
17
+ export const DEFAULT_BASE_URL = "https://api.hydradb.com";
18
+ export function newRawTransport(config, defaults) {
19
+ return {
20
+ token: config.token,
21
+ // Trailing slashes would produce `//byog/query`, which some proxies treat
22
+ // as a different path.
23
+ baseUrl: (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""),
24
+ timeoutMs: (config.timeoutSeconds ?? defaults.timeoutSeconds) * 1000,
25
+ maxRetries: config.maxRetries ?? defaults.maxRetries,
26
+ ...(config.fetchFn ? { fetchFn: config.fetchFn } : {}),
27
+ };
28
+ }
29
+ function isRetryable(status) {
30
+ return status === 429 || (status >= 500 && status <= 599);
31
+ }
32
+ /** Send with bounded retries on 429/5xx; the caller's abort ends retrying. */
33
+ export async function sendRaw(t, path, method, body, opts) {
34
+ let lastError;
35
+ for (let attempt = 0; attempt <= t.maxRetries; attempt++) {
36
+ try {
37
+ return await attemptRaw(t, path, method, body, opts);
38
+ }
39
+ catch (err) {
40
+ lastError = err;
41
+ const status = err instanceof HydraWrapperError ? err.status : undefined;
42
+ // A caller who cancelled is not waiting for a retry.
43
+ if (opts?.signal?.aborted)
44
+ throw err;
45
+ if (attempt === t.maxRetries)
46
+ break;
47
+ if (status != null && !isRetryable(status))
48
+ break;
49
+ // Exponential backoff. Bounded so a 429 on the last attempt does not
50
+ // hold the MCP host's tool timeout open for its own sake.
51
+ const delay = Math.min(250 * 2 ** attempt, 2000);
52
+ await new Promise((resolve) => setTimeout(resolve, delay));
53
+ }
54
+ }
55
+ throw lastError;
56
+ }
57
+ async function attemptRaw(t, path, method, body, opts) {
58
+ // The host's cancellation and our own deadline both have to be able to
59
+ // abort the request, and `AbortSignal.any` is not available on every Node
60
+ // 18 this package supports — so they are combined by hand.
61
+ const controller = new AbortController();
62
+ const timer = setTimeout(() => controller.abort(), t.timeoutMs);
63
+ const onAbort = () => controller.abort();
64
+ opts?.signal?.addEventListener("abort", onAbort, { once: true });
65
+ const doFetch = t.fetchFn ?? fetch;
66
+ try {
67
+ const response = await doFetch(`${t.baseUrl}${path}`, {
68
+ method,
69
+ headers: {
70
+ Authorization: `Bearer ${t.token}`,
71
+ "Content-Type": "application/json",
72
+ // CONTRACT §2 rule 6. The SDK sends this on every call; a
73
+ // hand-rolled path that omitted it would silently get v1
74
+ // behaviour from the same endpoints.
75
+ "API-Version": "2",
76
+ },
77
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
78
+ signal: controller.signal,
79
+ });
80
+ const text = await response.text();
81
+ let parsed;
82
+ try {
83
+ parsed = text === "" ? null : JSON.parse(text);
84
+ }
85
+ catch {
86
+ parsed = text;
87
+ }
88
+ if (!response.ok) {
89
+ // Hand the parsed envelope to the shared formatter so a raw-path
90
+ // failure reads exactly like an SDK one, error code and request id
91
+ // and all.
92
+ throw responseError(path, response.status, parsed);
93
+ }
94
+ return unwrap(parsed);
95
+ }
96
+ catch (err) {
97
+ if (err instanceof HydraWrapperError)
98
+ throw err;
99
+ // Distinguish our own deadline from the caller's cancellation: they
100
+ // need different actions, and "aborted" alone says neither.
101
+ if (err instanceof Error && err.name === "AbortError") {
102
+ if (opts?.signal?.aborted) {
103
+ throw new HydraWrapperError(`Hydra DB ${path} → ERR: request cancelled by the caller`, path, { cause: err });
104
+ }
105
+ throw new HydraWrapperError(`Hydra DB ${path} → ERR: request timed out after ${t.timeoutMs / 1000}s`, path, { cause: err });
106
+ }
107
+ throw translateError(path, err);
108
+ }
109
+ finally {
110
+ clearTimeout(timer);
111
+ opts?.signal?.removeEventListener("abort", onAbort);
112
+ }
113
+ }
114
+ //# sourceMappingURL=transport.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transport.js","sourceRoot":"","sources":["../../src/hydra/transport.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The OAuth 2.1 RESOURCE-SERVER half of MCP authorization (PRO-1790).
3
+ *
4
+ * This server never issues tokens and never talks to the user. It does three
5
+ * things the MCP authorization spec asks of a protected resource:
6
+ *
7
+ * 1. Advertise where its authorization server is, via an RFC 9728 Protected
8
+ * Resource Metadata document at `/.well-known/oauth-protected-resource`,
9
+ * and point at that document from every 401 (`WWW-Authenticate`).
10
+ * 2. Recognise an access token the authorization server issued and turn it
11
+ * into the same {@link RequestCredentials} an API key would produce, by
12
+ * asking the authorization server what the token stands for (RFC 7662
13
+ * introspection). The answer carries the API key the dashboard minted on
14
+ * the user's Approve click, so the tool layer keeps calling Hydra DB
15
+ * exactly as it does for a key: nothing downstream knows OAuth exists.
16
+ * 3. Refuse a token that was not issued for THIS server (audience binding,
17
+ * RFC 8707), so a token minted for another resource cannot be replayed
18
+ * here.
19
+ *
20
+ * All of it is additive and switched on only by configuration: with no
21
+ * `HYDRADB_OAUTH_ISSUER` the server behaves exactly as before.
22
+ */
23
+ /** Access tokens the dashboard issues carry this prefix; keys carry `sk_`. */
24
+ export declare const ACCESS_TOKEN_PREFIX = "hmat_";
25
+ /** The one scope this resource defines. Granularity is PRO-1789's problem. */
26
+ export declare const SCOPE = "hydradb";
27
+ export interface OAuthConfig {
28
+ /** The authorization server, e.g. `https://app.hydradb.com`. No trailing slash. */
29
+ issuer: string;
30
+ /** This server's canonical URL as clients know it, e.g. `https://mcp.hydradb.com`. */
31
+ resource: string;
32
+ /** Shared secret presented to the issuer's introspection endpoint. */
33
+ introspectionSecret: string;
34
+ /** Overrides for tests. */
35
+ fetchFn?: typeof fetch;
36
+ now?: () => number;
37
+ }
38
+ /** Everything an introspected token stands for. Mirrors the dashboard's response. */
39
+ export interface IntrospectedToken {
40
+ apiKey: string;
41
+ database?: string;
42
+ collection?: string;
43
+ /**
44
+ * Databases a per-call `database` argument may name. Absent means any (the
45
+ * user chose to let the app switch when asked); present means the user
46
+ * confined the app to exactly these on the consent screen, and this server
47
+ * is where that promise is kept, because the API key behind the token is
48
+ * org-wide and the key never leaves this process.
49
+ */
50
+ allowedDatabases?: string[];
51
+ /**
52
+ * Collections a per-call `collection` argument may name, on the same terms.
53
+ * Confinement has to cover both or it means nothing: a caller pinned to one
54
+ * database could still step sideways into a collection the consent screen
55
+ * never showed and, through `drop_collection`, delete it.
56
+ */
57
+ allowedCollections?: string[];
58
+ userId?: string;
59
+ clientId?: string;
60
+ clientName?: string;
61
+ /** Epoch seconds. */
62
+ expiresAt: number;
63
+ }
64
+ export type IntrospectionResult = {
65
+ ok: true;
66
+ token: IntrospectedToken;
67
+ } | {
68
+ ok: false;
69
+ reason: "invalid_token" | "wrong_audience" | "unavailable";
70
+ detail?: string;
71
+ };
72
+ /**
73
+ * Read the OAuth configuration, or `null` when the feature is off.
74
+ *
75
+ * All three values are needed: an issuer says where to send clients, a
76
+ * resource says which audience to accept, and the secret is what makes the
77
+ * introspection answer trustworthy. Half a configuration is treated as none,
78
+ * with a startup warning, rather than as a server that advertises an
79
+ * authorization flow it cannot complete.
80
+ */
81
+ export declare function resolveOAuthConfig(env?: Record<string, string | undefined>, warn?: (message: string) => void): OAuthConfig | null;
82
+ /** Where the Protected Resource Metadata document lives for this server. */
83
+ export declare function metadataUrl(config: OAuthConfig, mcpPath?: string): string;
84
+ /** The RFC 9728 document itself. */
85
+ export declare function protectedResourceMetadata(config: OAuthConfig): Record<string, unknown>;
86
+ /**
87
+ * The `WWW-Authenticate` value for a 401.
88
+ *
89
+ * `resource_metadata` is how a client that arrived with nothing learns where
90
+ * to log in (the MCP spec's first discovery step), and `scope` tells it what
91
+ * to ask for. `error` is included only when a token was PRESENTED and refused,
92
+ * so an anonymous first contact reads as "authenticate" rather than "your
93
+ * token is bad".
94
+ */
95
+ export declare function wwwAuthenticate(config: OAuthConfig, error?: "invalid_token" | "insufficient_scope", description?: string): string;
96
+ /** Whether a bearer value is one of OUR access tokens rather than an API key. */
97
+ export declare function isAccessToken(bearer: string | undefined): bearer is string;
98
+ /** Test-only. */
99
+ export declare function __resetIntrospectionCache(): void;
100
+ /**
101
+ * Ask the issuer what a token stands for.
102
+ *
103
+ * The response shape is the contract in PRO-1790: `active`, `aud`, `exp`,
104
+ * `api_key`, `database`, `collection`, `sub`, `client_id`, `client_name`.
105
+ * Audience is checked HERE, not trusted from the issuer's say-so: the issuer
106
+ * may legitimately serve several resources, and this server must only honour
107
+ * tokens minted for itself.
108
+ */
109
+ export declare function introspect(config: OAuthConfig, token: string): Promise<IntrospectionResult>;
package/dist/oauth.js ADDED
@@ -0,0 +1,228 @@
1
+ /**
2
+ * The OAuth 2.1 RESOURCE-SERVER half of MCP authorization (PRO-1790).
3
+ *
4
+ * This server never issues tokens and never talks to the user. It does three
5
+ * things the MCP authorization spec asks of a protected resource:
6
+ *
7
+ * 1. Advertise where its authorization server is, via an RFC 9728 Protected
8
+ * Resource Metadata document at `/.well-known/oauth-protected-resource`,
9
+ * and point at that document from every 401 (`WWW-Authenticate`).
10
+ * 2. Recognise an access token the authorization server issued and turn it
11
+ * into the same {@link RequestCredentials} an API key would produce, by
12
+ * asking the authorization server what the token stands for (RFC 7662
13
+ * introspection). The answer carries the API key the dashboard minted on
14
+ * the user's Approve click, so the tool layer keeps calling Hydra DB
15
+ * exactly as it does for a key: nothing downstream knows OAuth exists.
16
+ * 3. Refuse a token that was not issued for THIS server (audience binding,
17
+ * RFC 8707), so a token minted for another resource cannot be replayed
18
+ * here.
19
+ *
20
+ * All of it is additive and switched on only by configuration: with no
21
+ * `HYDRADB_OAUTH_ISSUER` the server behaves exactly as before.
22
+ */
23
+ import { createHash } from "node:crypto";
24
+ import { logger } from "./logger.js";
25
+ /** Access tokens the dashboard issues carry this prefix; keys carry `sk_`. */
26
+ export const ACCESS_TOKEN_PREFIX = "hmat_";
27
+ /** The one scope this resource defines. Granularity is PRO-1789's problem. */
28
+ export const SCOPE = "hydradb";
29
+ function stripSlash(url) {
30
+ return url.replace(/\/+$/, "");
31
+ }
32
+ /**
33
+ * Read the OAuth configuration, or `null` when the feature is off.
34
+ *
35
+ * All three values are needed: an issuer says where to send clients, a
36
+ * resource says which audience to accept, and the secret is what makes the
37
+ * introspection answer trustworthy. Half a configuration is treated as none,
38
+ * with a startup warning, rather than as a server that advertises an
39
+ * authorization flow it cannot complete.
40
+ */
41
+ export function resolveOAuthConfig(env = process.env, warn = (m) => console.error(`[hydradb-mcp] ${m}`)) {
42
+ const issuer = env.HYDRADB_OAUTH_ISSUER?.trim();
43
+ const resource = env.HYDRADB_MCP_PUBLIC_URL?.trim();
44
+ const introspectionSecret = env.HYDRADB_OAUTH_INTROSPECTION_SECRET?.trim();
45
+ if (!issuer && !resource && !introspectionSecret)
46
+ return null;
47
+ const missing = [
48
+ !issuer && "HYDRADB_OAUTH_ISSUER",
49
+ !resource && "HYDRADB_MCP_PUBLIC_URL",
50
+ !introspectionSecret && "HYDRADB_OAUTH_INTROSPECTION_SECRET",
51
+ ].filter(Boolean);
52
+ if (missing.length > 0) {
53
+ warn(`WARNING: OAuth is partially configured (missing ${missing.join(", ")}); ` +
54
+ "it stays OFF until all three are set.");
55
+ return null;
56
+ }
57
+ for (const [name, value] of [
58
+ ["HYDRADB_OAUTH_ISSUER", issuer],
59
+ ["HYDRADB_MCP_PUBLIC_URL", resource],
60
+ ]) {
61
+ if (!/^https?:\/\//i.test(value)) {
62
+ warn(`WARNING: ${name} must be an absolute http(s) URL; OAuth stays OFF.`);
63
+ return null;
64
+ }
65
+ }
66
+ return {
67
+ issuer: stripSlash(issuer),
68
+ resource: stripSlash(resource),
69
+ introspectionSecret: introspectionSecret,
70
+ };
71
+ }
72
+ /** Where the Protected Resource Metadata document lives for this server. */
73
+ export function metadataUrl(config, mcpPath = "") {
74
+ // RFC 9728 puts the well-known segment BEFORE the resource's path, so a
75
+ // server at /mcp advertises at /.well-known/oauth-protected-resource/mcp.
76
+ return `${config.resource}/.well-known/oauth-protected-resource${mcpPath}`;
77
+ }
78
+ /** The RFC 9728 document itself. */
79
+ export function protectedResourceMetadata(config) {
80
+ return {
81
+ resource: config.resource,
82
+ authorization_servers: [config.issuer],
83
+ scopes_supported: [SCOPE],
84
+ bearer_methods_supported: ["header"],
85
+ resource_name: "Hydra DB MCP",
86
+ resource_documentation: "https://docs.hydradb.com/mcp",
87
+ };
88
+ }
89
+ /**
90
+ * The `WWW-Authenticate` value for a 401.
91
+ *
92
+ * `resource_metadata` is how a client that arrived with nothing learns where
93
+ * to log in (the MCP spec's first discovery step), and `scope` tells it what
94
+ * to ask for. `error` is included only when a token was PRESENTED and refused,
95
+ * so an anonymous first contact reads as "authenticate" rather than "your
96
+ * token is bad".
97
+ */
98
+ export function wwwAuthenticate(config, error, description) {
99
+ const parts = [`Bearer realm="Hydra DB MCP"`];
100
+ if (error)
101
+ parts.push(`error="${error}"`);
102
+ if (description)
103
+ parts.push(`error_description="${description.replace(/"/g, "'")}"`);
104
+ parts.push(`resource_metadata="${metadataUrl(config)}"`);
105
+ parts.push(`scope="${SCOPE}"`);
106
+ return parts.join(", ");
107
+ }
108
+ /** Whether a bearer value is one of OUR access tokens rather than an API key. */
109
+ export function isAccessToken(bearer) {
110
+ return typeof bearer === "string" && bearer.startsWith(ACCESS_TOKEN_PREFIX);
111
+ }
112
+ // --- Introspection, with a bounded memo ---
113
+ /**
114
+ * Introspection answers are memoised per token so the hosted server, which
115
+ * builds a client per request, does not ask the dashboard on every tool call.
116
+ * Only successes are cached, for the shorter of the token's own expiry and
117
+ * this ceiling. The ceiling IS the revocation lag: measured end to end, a
118
+ * disconnect or a refresh-token reuse detection reaches this server exactly
119
+ * one ceiling later. Thirty seconds keeps an agent's burst of tool calls at
120
+ * one hop while keeping that lag short enough that a revoked token cannot do
121
+ * much with it.
122
+ */
123
+ const INTROSPECTION_CACHE_TTL_MS = 30000;
124
+ const INTROSPECTION_CACHE_MAX = 5000;
125
+ const INTROSPECTION_TIMEOUT_MS = 5000;
126
+ const cache = new Map();
127
+ /** Test-only. */
128
+ export function __resetIntrospectionCache() {
129
+ cache.clear();
130
+ }
131
+ function cacheKey(token) {
132
+ // Never keep the raw token as a Map key: it would sit in memory in the
133
+ // clear for as long as the entry lives.
134
+ return createHash("sha256").update(token).digest("hex");
135
+ }
136
+ /**
137
+ * Ask the issuer what a token stands for.
138
+ *
139
+ * The response shape is the contract in PRO-1790: `active`, `aud`, `exp`,
140
+ * `api_key`, `database`, `collection`, `sub`, `client_id`, `client_name`.
141
+ * Audience is checked HERE, not trusted from the issuer's say-so: the issuer
142
+ * may legitimately serve several resources, and this server must only honour
143
+ * tokens minted for itself.
144
+ */
145
+ export async function introspect(config, token) {
146
+ const now = config.now ?? Date.now;
147
+ const key = cacheKey(token);
148
+ const hit = cache.get(key);
149
+ if (hit && hit.expires > now())
150
+ return { ok: true, token: hit.token };
151
+ if (hit)
152
+ cache.delete(key);
153
+ const fetchFn = config.fetchFn ?? fetch;
154
+ const controller = new AbortController();
155
+ const timer = setTimeout(() => controller.abort(), INTROSPECTION_TIMEOUT_MS);
156
+ let body;
157
+ try {
158
+ const res = await fetchFn(`${config.issuer}/api/oauth/introspect`, {
159
+ method: "POST",
160
+ headers: {
161
+ "Content-Type": "application/x-www-form-urlencoded",
162
+ Accept: "application/json",
163
+ Authorization: `Bearer ${config.introspectionSecret}`,
164
+ },
165
+ body: new URLSearchParams({ token }).toString(),
166
+ signal: controller.signal,
167
+ });
168
+ if (res.status === 401 || res.status === 403) {
169
+ // Our secret was refused. That is an operator problem, not the
170
+ // caller's, and it must not read as "your token is invalid".
171
+ logger.error("introspection refused: HYDRADB_OAUTH_INTROSPECTION_SECRET is not accepted by the issuer");
172
+ return { ok: false, reason: "unavailable", detail: "introspection refused" };
173
+ }
174
+ if (!res.ok) {
175
+ return { ok: false, reason: "unavailable", detail: `introspection HTTP ${res.status}` };
176
+ }
177
+ body = (await res.json());
178
+ }
179
+ catch (err) {
180
+ return {
181
+ ok: false,
182
+ reason: "unavailable",
183
+ detail: err instanceof Error ? err.message : String(err),
184
+ };
185
+ }
186
+ finally {
187
+ clearTimeout(timer);
188
+ }
189
+ if (body.active !== true)
190
+ return { ok: false, reason: "invalid_token" };
191
+ const aud = body.aud;
192
+ const audiences = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
193
+ if (!audiences.some((a) => typeof a === "string" && stripSlash(a) === config.resource)) {
194
+ return { ok: false, reason: "wrong_audience" };
195
+ }
196
+ const apiKey = typeof body.api_key === "string" ? body.api_key : "";
197
+ const exp = typeof body.exp === "number" ? body.exp : 0;
198
+ if (!apiKey || exp <= Math.floor(now() / 1000)) {
199
+ return { ok: false, reason: "invalid_token" };
200
+ }
201
+ const stringList = (v) => Array.isArray(v)
202
+ ? v.filter((d) => typeof d === "string" && d.length > 0)
203
+ : undefined;
204
+ const allowed = stringList(body.databases);
205
+ const allowedCols = stringList(body.collections);
206
+ const resolved = {
207
+ apiKey,
208
+ ...(allowed ? { allowedDatabases: allowed } : {}),
209
+ ...(allowedCols ? { allowedCollections: allowedCols } : {}),
210
+ database: typeof body.database === "string" && body.database ? body.database : undefined,
211
+ collection: typeof body.collection === "string" && body.collection ? body.collection : undefined,
212
+ userId: typeof body.sub === "string" ? body.sub : undefined,
213
+ clientId: typeof body.client_id === "string" ? body.client_id : undefined,
214
+ clientName: typeof body.client_name === "string" ? body.client_name : undefined,
215
+ expiresAt: exp,
216
+ };
217
+ if (cache.size >= INTROSPECTION_CACHE_MAX) {
218
+ const oldest = cache.keys().next().value;
219
+ if (oldest != null)
220
+ cache.delete(oldest);
221
+ }
222
+ cache.set(key, {
223
+ token: resolved,
224
+ expires: Math.min(now() + INTROSPECTION_CACHE_TTL_MS, exp * 1000),
225
+ });
226
+ return { ok: true, token: resolved };
227
+ }
228
+ //# sourceMappingURL=oauth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oauth.js","sourceRoot":"","sources":["../src/oauth.ts"],"names":[],"mappings":"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"}
package/dist/server.d.ts CHANGED
@@ -19,12 +19,20 @@ export declare function inFlightCount(): number;
19
19
  export declare function legacyToolsEnabled(env?: NodeJS.ProcessEnv): boolean;
20
20
  /** Test-only: reset the once-per-process alias warning dedupe. */
21
21
  export declare function __resetAliasWarnings(): void;
22
+ export interface ServerOptions {
23
+ /**
24
+ * Register the OAuth-connection tools (currently `hydradb_databases`).
25
+ * Set by the HTTP transport when the request authenticated with an OAuth
26
+ * token; never for API keys, so their tool list is unchanged.
27
+ */
28
+ oauthTools?: boolean;
29
+ }
22
30
  export declare function createHydraDBServer(hydraOverride?: HydraDB,
23
31
  /**
24
32
  * Graph scope/gating override, for tests and embedders. Without it the graph
25
33
  * config is read from the environment exactly as the rest of the config is.
26
34
  */
27
- graphOverride?: Partial<GraphConfig>): import("@modelcontextprotocol/sdk/server").Server<{
35
+ graphOverride?: Partial<GraphConfig>, options?: ServerOptions): import("@modelcontextprotocol/sdk/server").Server<{
28
36
  method: string;
29
37
  params?: {
30
38
  [x: string]: unknown;