@elfa-ai/mcp 4.1.0 → 4.2.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 CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.2.0
4
+
5
+ ### Added
6
+
7
+ - **OAuth sign-in for hosted deployments** (`ELFA_MCP_AUTH=oauth`, HTTP transport only). Clients such as ChatGPT, Claude and Codex can connect with a browser sign-in instead of an API key. The server acts as an OAuth resource server under the MCP authorization spec:
8
+ - It serves RFC 9728 protected-resource metadata at `/.well-known/oauth-protected-resource/mcp` (and at the root path).
9
+ - A request with no credential gets `401` with a `WWW-Authenticate: Bearer resource_metadata=…` challenge.
10
+ - Each bearer token is checked against the authorization server's introspection endpoint, which returns the Elfa API key the request runs as. The token itself is never forwarded to the Elfa API.
11
+ - The token must be issued for this server: its audience has to match `ELFA_MCP_RESOURCE_URL`.
12
+ - Valid answers are cached for up to 60 seconds.
13
+ - An unreachable authorization server returns `503`, not `401`, so clients don't restart sign-in because of an outage.
14
+ - An `x-elfa-api-key` header still works in OAuth mode, so API-key users of the same deployment are unaffected.
15
+
16
+ ### Changed
17
+
18
+ - `getMarketEvents-v2` is no longer listed as unexposed. The Elfa API dropped it from the published spec, so there is nothing left to account for.
19
+ - In OAuth mode `ELFA_API_KEY` is ignored. Every request brings its own credential, and an environment key would otherwise be handed to any caller that sends none. API-key mode keeps its environment fallback.
20
+
3
21
  ## 4.1.0
4
22
 
5
23
  ### Security
package/README.md CHANGED
@@ -79,7 +79,6 @@ Some MCP clients apply their own timeout, often around 60 seconds. `narratives`
79
79
 
80
80
  Not exposed as tools:
81
81
 
82
- - `getMarketEvents-v2` — Available only to select Enterprise customers, and the published operation takes no parameters. Contact sales@elfa.ai for access.
83
82
  - `chat-stream-v2` — A tool call returns one result, so streaming adds nothing. market_chat covers the same analysis.
84
83
  - `auto-stream-queries-v2` — Long lived streams have no tool equivalent. Poll with auto_query.
85
84
  - `auto-stream-query-v2` — Long lived streams have no tool equivalent. Poll with auto_query.
@@ -123,7 +122,7 @@ The same server runs over Streamable HTTP for hosted deployments:
123
122
  ELFA_MCP_TRANSPORT=http ELFA_MCP_PORT=3000 npx -y @elfa-ai/mcp
124
123
  ```
125
124
 
126
- It is stateless — no sessions, one server instance per request, safe behind a load balancer. Credentials come from the `x-elfa-api-key` request header, falling back to the environment.
125
+ It is stateless — no sessions, one server instance per request, safe behind a load balancer. Credentials come from the `x-elfa-api-key` request header, falling back to the environment, or from an OAuth sign-in (see below).
127
126
 
128
127
  DNS rebinding protection is on by default. The server accepts only the loopback names it binds — `localhost:PORT` and `127.0.0.1:PORT` — which covers the local run above and nothing else. Any deployment that answers on a different `Host` must list the values it serves:
129
128
 
@@ -143,6 +142,42 @@ That includes a public domain, a reverse proxy, and a container that maps the po
143
142
 
144
143
  Set `ELFA_MCP_ALLOWED_ORIGINS` as well when browsers call the server directly. It complements the host allowlist rather than replacing it: a rebound request is same origin, so it carries no `Origin` header for that list to check, and the `Host` header is the only one still naming the attacker's domain.
145
144
 
145
+ ### OAuth sign-in
146
+
147
+ A hosted server can let clients sign in through a browser instead of sending an API key. Set `ELFA_MCP_AUTH=oauth` and the server becomes an OAuth resource server under the MCP authorization spec:
148
+
149
+ 1. A request with no credential gets `401` and a `WWW-Authenticate` challenge.
150
+ 2. The challenge points the client to the protected-resource metadata at `/.well-known/oauth-protected-resource/mcp`.
151
+ 3. That metadata names the authorization server, where the user signs in.
152
+ 4. The client then sends `Authorization: Bearer <token>`.
153
+ 5. The server checks the token against the authorization server's introspection endpoint. The endpoint answers with the Elfa API key the request runs as, so the token never reaches the Elfa API.
154
+
155
+ ```bash
156
+ ELFA_MCP_TRANSPORT=http \
157
+ ELFA_MCP_AUTH=oauth \
158
+ ELFA_MCP_RESOURCE_URL=https://mcp.example.com/mcp \
159
+ ELFA_OAUTH_ISSUER=https://auth.example.com \
160
+ ELFA_OAUTH_INTROSPECTION_URL=https://auth.example.com/introspect \
161
+ ELFA_OAUTH_INTROSPECTION_TOKEN=... \
162
+ ELFA_MCP_ALLOWED_HOSTS=mcp.example.com \
163
+ npx -y @elfa-ai/mcp
164
+ ```
165
+
166
+ | Variable | Required in OAuth mode | Purpose |
167
+ | --- | --- | --- |
168
+ | `ELFA_MCP_AUTH` | yes | `oauth` to enable, default `apikey` |
169
+ | `ELFA_MCP_RESOURCE_URL` | yes | Canonical URL of this endpoint. Tokens must be issued for exactly this value |
170
+ | `ELFA_OAUTH_ISSUER` | yes | Authorization server listed in the metadata |
171
+ | `ELFA_OAUTH_INTROSPECTION_URL` | yes | Where tokens are checked |
172
+ | `ELFA_OAUTH_INTROSPECTION_TOKEN` | yes | Bearer sent to the introspection endpoint |
173
+ | `ELFA_OAUTH_SCOPES` | no | Comma separated scopes to advertise, default `elfa` |
174
+
175
+ Notes:
176
+
177
+ - An `x-elfa-api-key` header still works in OAuth mode.
178
+ - `ELFA_API_KEY` is ignored in OAuth mode, so a caller with no credential never runs as the server's own key.
179
+ - Valid tokens are cached for up to a minute, which bounds how long a revoked token keeps working.
180
+
146
181
  ## Safety
147
182
 
148
183
  `api_status` is the fastest way to tell an auth problem from a credit problem.
package/dist/index.js CHANGED
@@ -112,15 +112,44 @@ function list(value) {
112
112
  if (!value) return [];
113
113
  return value.split(",").map((entry) => entry.trim()).filter(Boolean);
114
114
  }
115
+ var ConfigError = class extends Error {
116
+ };
117
+ function oauthConfig(env) {
118
+ const required = {
119
+ ELFA_MCP_RESOURCE_URL: env.ELFA_MCP_RESOURCE_URL,
120
+ ELFA_OAUTH_ISSUER: env.ELFA_OAUTH_ISSUER,
121
+ ELFA_OAUTH_INTROSPECTION_URL: env.ELFA_OAUTH_INTROSPECTION_URL,
122
+ ELFA_OAUTH_INTROSPECTION_TOKEN: env.ELFA_OAUTH_INTROSPECTION_TOKEN
123
+ };
124
+ const missing = Object.entries(required).filter(([, value]) => !value).map(([name]) => name);
125
+ if (missing.length > 0) {
126
+ throw new ConfigError(
127
+ `ELFA_MCP_AUTH=oauth requires ${missing.join(", ")}.`
128
+ );
129
+ }
130
+ return {
131
+ resource: required.ELFA_MCP_RESOURCE_URL,
132
+ issuer: required.ELFA_OAUTH_ISSUER,
133
+ introspectionUrl: required.ELFA_OAUTH_INTROSPECTION_URL,
134
+ introspectionToken: required.ELFA_OAUTH_INTROSPECTION_TOKEN,
135
+ scopes: list(env.ELFA_OAUTH_SCOPES).length > 0 ? list(env.ELFA_OAUTH_SCOPES) : ["elfa"]
136
+ };
137
+ }
115
138
  function loadConfig(env = process.env) {
116
139
  const transport = env.ELFA_MCP_TRANSPORT === "http" ? "http" : "stdio";
140
+ const auth = env.ELFA_MCP_AUTH === "oauth" ? "oauth" : "apikey";
141
+ if (auth === "oauth" && transport !== "http") {
142
+ throw new ConfigError("ELFA_MCP_AUTH=oauth needs ELFA_MCP_TRANSPORT=http.");
143
+ }
117
144
  return {
118
145
  transport,
119
146
  port: num(env.ELFA_MCP_PORT, DEFAULT_PORT),
120
147
  host: env.ELFA_MCP_HOST || DEFAULT_HOST,
121
148
  allowedOrigins: list(env.ELFA_MCP_ALLOWED_ORIGINS),
122
149
  allowedHosts: list(env.ELFA_MCP_ALLOWED_HOSTS),
123
- apiKey: env.ELFA_API_KEY || void 0,
150
+ // In oauth mode every request brings its own credential. An environment
151
+ // key would be handed to any caller that sends none, so it is ignored.
152
+ apiKey: auth === "oauth" ? void 0 : env.ELFA_API_KEY || void 0,
124
153
  baseUrl: env.ELFA_BASE_URL || void 0,
125
154
  extraHeaders: headers(env.ELFA_EXTRA_HEADERS),
126
155
  timeout: num(env.ELFA_TIMEOUT, DEFAULT_TIMEOUT),
@@ -128,7 +157,9 @@ function loadConfig(env = process.env) {
128
157
  maxResponseChars: num(
129
158
  env.ELFA_MCP_MAX_RESPONSE_CHARS,
130
159
  DEFAULT_MAX_RESPONSE_CHARS
131
- )
160
+ ),
161
+ auth,
162
+ oauth: auth === "oauth" ? oauthConfig(env) : void 0
132
163
  };
133
164
  }
134
165
 
@@ -142,7 +173,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
142
173
  // package.json
143
174
  var package_default = {
144
175
  name: "@elfa-ai/mcp",
145
- version: "4.1.0",
176
+ version: "4.2.0",
146
177
  description: "Model Context Protocol server for the Elfa API - crypto social intelligence and the Auto condition engine",
147
178
  license: "MIT",
148
179
  author: "Elfa AI",
@@ -1230,6 +1261,111 @@ function createServer(deps) {
1230
1261
  return server;
1231
1262
  }
1232
1263
 
1264
+ // src/oauth.ts
1265
+ import { createHash } from "crypto";
1266
+ var CACHE_TTL_MS = 6e4;
1267
+ var CACHE_MAX_ENTRIES = 1e4;
1268
+ var INTROSPECTION_TIMEOUT_MS = 5e3;
1269
+ var IntrospectionUnavailableError = class extends Error {
1270
+ };
1271
+ function protectedResourceMetadataPath(resource) {
1272
+ const { pathname } = new URL(resource);
1273
+ const suffix = pathname === "/" ? "" : pathname.replace(/\/+$/, "");
1274
+ return `/.well-known/oauth-protected-resource${suffix}`;
1275
+ }
1276
+ function protectedResourceMetadataUrl(resource) {
1277
+ const url = new URL(resource);
1278
+ return `${url.origin}${protectedResourceMetadataPath(resource)}`;
1279
+ }
1280
+ function protectedResourceMetadata(config) {
1281
+ return {
1282
+ resource: config.resource,
1283
+ authorization_servers: [config.issuer],
1284
+ scopes_supported: config.scopes,
1285
+ bearer_methods_supported: ["header"],
1286
+ resource_name: "Elfa",
1287
+ resource_documentation: "https://docs.elfa.ai/mcp"
1288
+ };
1289
+ }
1290
+ function bearerChallenge(config, error) {
1291
+ const parts = [
1292
+ `resource_metadata="${protectedResourceMetadataUrl(config.resource)}"`,
1293
+ `scope="${config.scopes.join(" ")}"`
1294
+ ];
1295
+ if (error) {
1296
+ parts.unshift(`error="${error.code}"`);
1297
+ parts.push(`error_description="${error.description.replace(/"/g, "'")}"`);
1298
+ }
1299
+ return `Bearer ${parts.join(", ")}`;
1300
+ }
1301
+ function bearerToken(authorization) {
1302
+ if (!authorization) return void 0;
1303
+ const match = /^Bearer\s+(\S+)\s*$/i.exec(authorization);
1304
+ return match?.[1];
1305
+ }
1306
+ var TokenVerifier = class {
1307
+ constructor(config, fetchImpl = fetch, now = Date.now) {
1308
+ this.config = config;
1309
+ this.fetchImpl = fetchImpl;
1310
+ this.now = now;
1311
+ }
1312
+ config;
1313
+ fetchImpl;
1314
+ now;
1315
+ cache = /* @__PURE__ */ new Map();
1316
+ /** The API key for a valid token, or undefined when the token is not valid. */
1317
+ async verify(token) {
1318
+ const cacheKey = createHash("sha256").update(token).digest("hex");
1319
+ const cached = this.cache.get(cacheKey);
1320
+ if (cached && cached.expiresAtMs > this.now()) return cached;
1321
+ if (cached) this.cache.delete(cacheKey);
1322
+ const body = await this.introspect(token);
1323
+ if (!body.active || typeof body.elfa_api_key !== "string") return void 0;
1324
+ const audiences = Array.isArray(body.aud) ? body.aud : [body.aud];
1325
+ if (!audiences.includes(this.config.resource)) return void 0;
1326
+ const tokenExpiryMs = typeof body.exp === "number" ? body.exp * 1e3 : this.now() + CACHE_TTL_MS;
1327
+ if (tokenExpiryMs <= this.now()) return void 0;
1328
+ const verified = {
1329
+ apiKey: body.elfa_api_key,
1330
+ expiresAtMs: Math.min(tokenExpiryMs, this.now() + CACHE_TTL_MS)
1331
+ };
1332
+ if (this.cache.size >= CACHE_MAX_ENTRIES) {
1333
+ const oldest = this.cache.keys().next().value;
1334
+ if (oldest !== void 0) this.cache.delete(oldest);
1335
+ }
1336
+ this.cache.set(cacheKey, verified);
1337
+ return verified;
1338
+ }
1339
+ async introspect(token) {
1340
+ let response;
1341
+ try {
1342
+ response = await this.fetchImpl(this.config.introspectionUrl, {
1343
+ method: "POST",
1344
+ headers: {
1345
+ authorization: `Bearer ${this.config.introspectionToken}`,
1346
+ "content-type": "application/json"
1347
+ },
1348
+ body: JSON.stringify({ token }),
1349
+ signal: AbortSignal.timeout(INTROSPECTION_TIMEOUT_MS)
1350
+ });
1351
+ } catch (error) {
1352
+ throw new IntrospectionUnavailableError(
1353
+ error instanceof Error ? error.message : String(error)
1354
+ );
1355
+ }
1356
+ if (!response.ok) {
1357
+ throw new IntrospectionUnavailableError(
1358
+ `introspection returned HTTP ${response.status}`
1359
+ );
1360
+ }
1361
+ try {
1362
+ return await response.json();
1363
+ } catch {
1364
+ throw new IntrospectionUnavailableError("introspection returned invalid JSON");
1365
+ }
1366
+ }
1367
+ };
1368
+
1233
1369
  // src/http.ts
1234
1370
  var MCP_PATH = "/mcp";
1235
1371
  function header(req, name) {
@@ -1251,8 +1387,10 @@ function defaultAllowedHosts(config) {
1251
1387
  }
1252
1388
  return [...hosts];
1253
1389
  }
1254
- function createHttpApp(config) {
1390
+ function createHttpApp(config, options = {}) {
1255
1391
  const app = express();
1392
+ const oauth = config.oauth;
1393
+ const verifier = options.verifier ?? (oauth ? new TokenVerifier(oauth) : void 0);
1256
1394
  app.disable("x-powered-by");
1257
1395
  app.use(express.json({ limit: "4mb" }));
1258
1396
  app.use(
@@ -1267,6 +1405,47 @@ function createHttpApp(config) {
1267
1405
  app.get("/healthz", (_req, res) => {
1268
1406
  res.json({ ok: true });
1269
1407
  });
1408
+ if (oauth) {
1409
+ const sendMetadata = (_req, res) => {
1410
+ res.setHeader("Access-Control-Allow-Origin", "*");
1411
+ res.setHeader("Cache-Control", "public, max-age=300");
1412
+ res.json(protectedResourceMetadata(oauth));
1413
+ };
1414
+ const metadataPath = protectedResourceMetadataPath(oauth.resource);
1415
+ app.get(metadataPath, sendMetadata);
1416
+ if (metadataPath !== "/.well-known/oauth-protected-resource") {
1417
+ app.get("/.well-known/oauth-protected-resource", sendMetadata);
1418
+ }
1419
+ }
1420
+ async function resolveApiKey(req, res) {
1421
+ const headerKey = header(req, "x-elfa-api-key");
1422
+ if (headerKey || !oauth || !verifier) return headerKey ?? null;
1423
+ const token = bearerToken(header(req, "authorization"));
1424
+ if (!token) {
1425
+ res.setHeader("WWW-Authenticate", bearerChallenge(oauth));
1426
+ jsonRpcError(res, 401, "Authorization required. Sign in to Elfa to use this server.");
1427
+ return void 0;
1428
+ }
1429
+ try {
1430
+ const verified = await verifier.verify(token);
1431
+ if (verified) return verified.apiKey;
1432
+ } catch (error) {
1433
+ if (error instanceof IntrospectionUnavailableError) {
1434
+ jsonRpcError(res, 503, "Authorization service unavailable, try again shortly.");
1435
+ return void 0;
1436
+ }
1437
+ throw error;
1438
+ }
1439
+ res.setHeader(
1440
+ "WWW-Authenticate",
1441
+ bearerChallenge(oauth, {
1442
+ code: "invalid_token",
1443
+ description: "The access token is invalid or expired"
1444
+ })
1445
+ );
1446
+ jsonRpcError(res, 401, "Invalid or expired access token.");
1447
+ return void 0;
1448
+ }
1270
1449
  app.post(MCP_PATH, async (req, res) => {
1271
1450
  const origin = header(req, "origin");
1272
1451
  if (origin && config.allowedOrigins.length > 0 && !config.allowedOrigins.includes(origin)) {
@@ -1276,9 +1455,9 @@ function createHttpApp(config) {
1276
1455
  let server;
1277
1456
  let transport;
1278
1457
  try {
1279
- const deps = buildDeps(config, {
1280
- apiKey: header(req, "x-elfa-api-key")
1281
- });
1458
+ const apiKey = await resolveApiKey(req, res);
1459
+ if (apiKey === void 0) return;
1460
+ const deps = buildDeps(config, { apiKey: apiKey ?? void 0 });
1282
1461
  server = createServer(deps);
1283
1462
  transport = new StreamableHTTPServerTransport({
1284
1463
  enableDnsRebindingProtection: true,
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "server": {
3
3
  "name": "elfa",
4
- "version": "4.1.0",
4
+ "version": "4.2.0",
5
5
  "docs": "https://docs.elfa.ai",
6
6
  "spec": "https://docs.elfa.ai/swagger/swagger.json",
7
7
  "specScope": "/v2"
@@ -193,11 +193,6 @@
193
193
  }
194
194
  ],
195
195
  "unexposed": [
196
- {
197
- "operationId": "getMarketEvents-v2",
198
- "reason": "restricted",
199
- "note": "Available only to select Enterprise customers, and the published operation takes no parameters. Contact sales@elfa.ai for access."
200
- },
201
196
  {
202
197
  "operationId": "chat-stream-v2",
203
198
  "reason": "sse",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elfa-ai/mcp",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "description": "Model Context Protocol server for the Elfa API - crypto social intelligence and the Auto condition engine",
5
5
  "license": "MIT",
6
6
  "author": "Elfa AI",