@elfa-ai/mcp 3.0.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 +61 -0
- package/README.md +57 -9
- package/dist/index.js +207 -55
- package/manifest.json +3 -18
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,66 @@
|
|
|
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
|
+
|
|
21
|
+
## 4.1.0
|
|
22
|
+
|
|
23
|
+
### Security
|
|
24
|
+
|
|
25
|
+
- **DNS rebinding protection is now armed by default on the HTTP transport.** `enableDnsRebindingProtection` was only passed when `ELFA_MCP_ALLOWED_ORIGINS` was set, and that variable is unset in the documented local run (`ELFA_MCP_TRANSPORT=http npx -y @elfa-ai/mcp`), so the control shipped present but disarmed. The transport accepted any `Host` and any `Origin` while holding `ELFA_API_KEY`, which let a page the user visits re-point its own hostname at `127.0.0.1` and reach `tools/list` and `tools/call` at that process's own privilege — including the `auto_*` tools that spend credits. Binding to `127.0.0.1` was never a mitigation, because the victim's browser is already on loopback.
|
|
26
|
+
|
|
27
|
+
Setting the origin allowlist did not close it either. `allowedOrigins` was passed but `allowedHosts` never was, and the SDK guards each check with its own `length > 0` test, so `Host` validation never ran. An origin allowlist cannot carry this alone: a rebound request is same-origin and sends no `Origin` header, which leaves `Host` as the only header still naming the attacker's domain.
|
|
28
|
+
|
|
29
|
+
Refs GHSA-w48q-cv73-mx4w / CVE-2025-66414. Reported by mcpdone.
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- **`ELFA_MCP_ALLOWED_HOSTS`**, a comma separated `Host` allowlist. It defaults to the loopback names the server binds — `ELFA_MCP_HOST:PORT`, `localhost:PORT` and `127.0.0.1:PORT` — so the documented local run is closed with no configuration.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- **Action required for hosted deployments.** The host allowlist fails closed, so a deployment answering on any `Host` other than the loopback names the server binds now returns `403` until it sets `ELFA_MCP_ALLOWED_HOSTS`. That covers a public domain, a reverse proxy, and a container that maps the port to a different one than the server binds, since the default is derived from `ELFA_MCP_PORT`.
|
|
38
|
+
|
|
39
|
+
A minor rather than a major, because nothing about the interaction with this server changes: the eleven tools, their arguments, the stdio transport and every client-facing contract are untouched. A self-hosted HTTP deployment needs one environment variable.
|
|
40
|
+
|
|
41
|
+
**Migration:** set `ELFA_MCP_ALLOWED_HOSTS` to the host values the deployment serves, for example `ELFA_MCP_ALLOWED_HOSTS=mcp.example.com`. The stdio transport is unaffected and needs no change.
|
|
42
|
+
|
|
43
|
+
## 4.0.0
|
|
44
|
+
|
|
45
|
+
### Removed
|
|
46
|
+
|
|
47
|
+
- **`ELFA_HMAC_SECRET` and request signing are gone.** Elfa no longer documents HMAC signing for `/v2/auto/*` — the API key alone authenticates every route, including mutations. Removed here: the `ELFA_HMAC_SECRET` environment variable, the `x-elfa-hmac-secret` request header on the HTTP transport, the `elfa_hmac_secret` field in the Claude Desktop bundle, and the registry entry in `server.json`.
|
|
48
|
+
|
|
49
|
+
**Migration:** unset `ELFA_HMAC_SECRET` and stop sending `x-elfa-hmac-secret`. Neither is read any more. Nothing replaces them.
|
|
50
|
+
|
|
51
|
+
- **The client-side signing gate is gone.** `auto_query_write` and `auto_draft` no longer inspect a query's action shape and pre-reject it before sending. Every action type Elfa documents for API-key Auto — `notify`, `telegram_bot`, `webhook`, `llm` — is accepted with the API key alone, so the gate could only produce false rejections.
|
|
52
|
+
|
|
53
|
+
- **`hmacEnabled` is no longer reported by `api_status`.** The field was removed from the key-status response, so surfacing it would report something the API no longer sends.
|
|
54
|
+
|
|
55
|
+
- **`pacifica` is no longer a valid `auto_validate` exchange.** The documented enum for `method=symbol` is now `hyperliquid`, `gmx`, `binance`.
|
|
56
|
+
|
|
57
|
+
### Changed
|
|
58
|
+
|
|
59
|
+
- **Spec refreshed to `2.6.3`** from `2.5.0`. A credit is now $0.0145, so the x402 reference prices move to $0.0145 (1 credit), $0.0725 (5 credits) and $0.261 (18 credits). Accounts already on PAYG keep $0.009 per credit until 28 September 2026, 16:00 UTC.
|
|
60
|
+
- **`getMarketEvents-v2` is recorded as deliberately unexposed.** It is limited to select Enterprise customers and the published operation takes no parameters, so there is no usable tool surface. Eleven tools, unchanged.
|
|
61
|
+
- Install troubleshooting no longer claims a `403` on an Auto call means Auto is not enabled in the portal. There is no separate Auto enablement step; a new API key can call `/v2/auto/*` immediately.
|
|
62
|
+
- `SECURITY.md` now states that the API key is read from the environment on stdio and can arrive per request as `x-elfa-api-key` over HTTP, and refers to the tools by their current names.
|
|
63
|
+
|
|
3
64
|
## 3.0.0
|
|
4
65
|
|
|
5
66
|
### Removed
|
package/README.md
CHANGED
|
@@ -48,7 +48,6 @@ Ask *"what's trending in crypto right now?"* to confirm it works.
|
|
|
48
48
|
| Variable | Required | Purpose |
|
|
49
49
|
| --- | --- | --- |
|
|
50
50
|
| `ELFA_API_KEY` | yes | Authenticates every request |
|
|
51
|
-
| `ELFA_HMAC_SECRET` | no | Signs Auto mutations when signing is required. Notification-only query mutations do not need it |
|
|
52
51
|
| `ELFA_TIMEOUT` | no | Request timeout in ms, default `120000` |
|
|
53
52
|
| `ELFA_RETRIES` | no | Retries on failure, default `0` |
|
|
54
53
|
| `ELFA_MCP_MAX_RESPONSE_CHARS` | no | Response size ceiling, default `60000` |
|
|
@@ -58,8 +57,6 @@ The timeout is high and retries are off on purpose. The interpretation endpoints
|
|
|
58
57
|
|
|
59
58
|
Some MCP clients apply their own timeout, often around 60 seconds. `narratives` and `market_chat` can exceed that; the request still completes and is still charged, even if the client gives up first.
|
|
60
59
|
|
|
61
|
-
Without `ELFA_HMAC_SECRET` notification-only Auto mutations still work. Anything else returns a message telling you what to set.
|
|
62
|
-
|
|
63
60
|
## Tools
|
|
64
61
|
|
|
65
62
|
<!-- tools:start -->
|
|
@@ -69,10 +66,10 @@ Without `ELFA_HMAC_SECRET` notification-only Auto mutations still work. Anything
|
|
|
69
66
|
| Tool | Mode | Cost | What it does |
|
|
70
67
|
| --- | --- | --- | --- |
|
|
71
68
|
| `api_status` | read | Free | Check API key tier, credit usage and remaining requests. Also confirms the API is reachable. |
|
|
72
|
-
| `mentions` | read | 1 per call | Social mentions from X and Telegram. mode=top ranks a ticker's mentions by engagement, mode=search filters by keyword or account, mode=news returns the token news feed. |
|
|
69
|
+
| `mentions` | read | 1 per call | Social mentions from X and Telegram. mode=top ranks a ticker's mentions by engagement, mode=search filters by keyword or account, mode=news returns the token news feed, which is X posts from accounts tagged as news sources rather than articles from news outlets. |
|
|
73
70
|
| `trending` | read | 1 per call | What is gaining social attention. scope=tokens for tickers, scope=contracts_twitter or scope=contracts_telegram for contract addresses. |
|
|
74
71
|
| `narratives` | read | 5 per call | Written narrative analysis with source links. scope=market extracts market-wide narratives, scope=keywords summarises events for specific keywords. |
|
|
75
|
-
| `account_stats` | read | 1 per call | Smart follower and engagement stats for an X account. |
|
|
72
|
+
| `account_stats` | read | 1 per call | Smart follower and engagement stats for an X account. Legacy: it still works, but will be removed on 28 October 2026. |
|
|
76
73
|
| `market_chat` | read | Varies by speed | Ask for written market analysis. Supports conversational chat, macro overview, quick summary, token intro, token analysis and account analysis. |
|
|
77
74
|
| `auto_build` | read | 1 plus LLM usage | Turn a plain-language monitoring request into an EQL query. Returns a draft to validate and activate, it does not activate anything itself. |
|
|
78
75
|
| `auto_validate` | read | Free | Check EQL syntax and get a cost estimate before activating, or check that a symbol has market data on a venue. |
|
|
@@ -85,9 +82,6 @@ Not exposed as tools:
|
|
|
85
82
|
- `chat-stream-v2` — A tool call returns one result, so streaming adds nothing. market_chat covers the same analysis.
|
|
86
83
|
- `auto-stream-queries-v2` — Long lived streams have no tool equivalent. Poll with auto_query.
|
|
87
84
|
- `auto-stream-query-v2` — Long lived streams have no tool equivalent. Poll with auto_query.
|
|
88
|
-
- `auto-list-exchanges-v2` — Exchange connections are no longer part of the documented Auto surface.
|
|
89
|
-
- `auto-connect-exchange-v2` — Exchange connections are no longer part of the documented Auto surface.
|
|
90
|
-
- `auto-disconnect-exchange-v2` — Exchange connections are no longer part of the documented Auto surface.
|
|
91
85
|
|
|
92
86
|
<!-- tools:end -->
|
|
93
87
|
|
|
@@ -128,7 +122,61 @@ The same server runs over Streamable HTTP for hosted deployments:
|
|
|
128
122
|
ELFA_MCP_TRANSPORT=http ELFA_MCP_PORT=3000 npx -y @elfa-ai/mcp
|
|
129
123
|
```
|
|
130
124
|
|
|
131
|
-
It is stateless — no sessions, one server instance per request, safe behind a load balancer. Credentials come from `x-elfa-api-key`
|
|
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).
|
|
126
|
+
|
|
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:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
ELFA_MCP_ALLOWED_HOSTS=mcp.example.com
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
That includes a public domain, a reverse proxy, and a container that maps the port to a different one than the server binds. A `Host` the list does not cover is rejected with 403.
|
|
134
|
+
|
|
135
|
+
| Variable | Required | Purpose |
|
|
136
|
+
| --- | --- | --- |
|
|
137
|
+
| `ELFA_MCP_TRANSPORT` | no | `http` to serve over Streamable HTTP, default `stdio` |
|
|
138
|
+
| `ELFA_MCP_HOST` | no | Bind address, default `127.0.0.1` |
|
|
139
|
+
| `ELFA_MCP_PORT` | no | Bind port, default `3000` |
|
|
140
|
+
| `ELFA_MCP_ALLOWED_HOSTS` | no | Comma separated `Host` allowlist, defaults to the loopback names bound |
|
|
141
|
+
| `ELFA_MCP_ALLOWED_ORIGINS` | no | Comma separated `Origin` allowlist |
|
|
142
|
+
|
|
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.
|
|
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.
|
|
132
180
|
|
|
133
181
|
## Safety
|
|
134
182
|
|
package/dist/index.js
CHANGED
|
@@ -18,11 +18,8 @@ function messageOf(error) {
|
|
|
18
18
|
if (typeof error === "string") return error;
|
|
19
19
|
return "Unknown error";
|
|
20
20
|
}
|
|
21
|
-
function missingCredential(
|
|
22
|
-
|
|
23
|
-
return `No Elfa API key configured. Set ELFA_API_KEY in the MCP server environment, or send it as an x-elfa-api-key header when using the HTTP transport. Get a key at ${DOCS}.`;
|
|
24
|
-
}
|
|
25
|
-
return `This action requires request signing. Set ELFA_HMAC_SECRET in the MCP server environment, then retry. Notification-only Auto queries work without it. See ${DOCS}/auto/api-key-auth.`;
|
|
21
|
+
function missingCredential() {
|
|
22
|
+
return `No Elfa API key configured. Set ELFA_API_KEY in the MCP server environment, or send it as an x-elfa-api-key header when using the HTTP transport. Get a key at ${DOCS}.`;
|
|
26
23
|
}
|
|
27
24
|
function describeError(error) {
|
|
28
25
|
const status = statusOf(error);
|
|
@@ -31,7 +28,7 @@ function describeError(error) {
|
|
|
31
28
|
case 400:
|
|
32
29
|
return `Request rejected as invalid: ${detail}. Check the argument values and retry.`;
|
|
33
30
|
case 401:
|
|
34
|
-
return `Authentication failed: ${detail}. Verify ELFA_API_KEY
|
|
31
|
+
return `Authentication failed: ${detail}. Verify ELFA_API_KEY.`;
|
|
35
32
|
case 402:
|
|
36
33
|
return `Out of credits: ${detail}. Top up or upgrade the plan, then retry.`;
|
|
37
34
|
case 403:
|
|
@@ -60,21 +57,18 @@ var CredentialError = class extends Error {
|
|
|
60
57
|
};
|
|
61
58
|
function buildDeps(config, overrides = {}) {
|
|
62
59
|
const apiKey = overrides.apiKey ?? config.apiKey;
|
|
63
|
-
const hmacSecret = overrides.hmacSecret ?? config.hmacSecret;
|
|
64
60
|
if (!apiKey) {
|
|
65
|
-
throw new CredentialError(missingCredential(
|
|
61
|
+
throw new CredentialError(missingCredential());
|
|
66
62
|
}
|
|
67
63
|
const sdk = new ElfaSDK({
|
|
68
64
|
elfaApiKey: apiKey,
|
|
69
65
|
timeout: config.timeout,
|
|
70
66
|
retries: config.retries,
|
|
71
|
-
...hmacSecret ? { hmacSecret } : {},
|
|
72
67
|
...config.baseUrl ? { baseUrl: config.baseUrl } : {},
|
|
73
68
|
...config.extraHeaders ? { headers: config.extraHeaders } : {}
|
|
74
69
|
});
|
|
75
70
|
return {
|
|
76
71
|
sdk,
|
|
77
|
-
hasHmac: Boolean(hmacSecret),
|
|
78
72
|
maxResponseChars: config.maxResponseChars
|
|
79
73
|
};
|
|
80
74
|
}
|
|
@@ -118,15 +112,44 @@ function list(value) {
|
|
|
118
112
|
if (!value) return [];
|
|
119
113
|
return value.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
120
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
|
+
}
|
|
121
138
|
function loadConfig(env = process.env) {
|
|
122
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
|
+
}
|
|
123
144
|
return {
|
|
124
145
|
transport,
|
|
125
146
|
port: num(env.ELFA_MCP_PORT, DEFAULT_PORT),
|
|
126
147
|
host: env.ELFA_MCP_HOST || DEFAULT_HOST,
|
|
127
148
|
allowedOrigins: list(env.ELFA_MCP_ALLOWED_ORIGINS),
|
|
128
|
-
|
|
129
|
-
|
|
149
|
+
allowedHosts: list(env.ELFA_MCP_ALLOWED_HOSTS),
|
|
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,
|
|
130
153
|
baseUrl: env.ELFA_BASE_URL || void 0,
|
|
131
154
|
extraHeaders: headers(env.ELFA_EXTRA_HEADERS),
|
|
132
155
|
timeout: num(env.ELFA_TIMEOUT, DEFAULT_TIMEOUT),
|
|
@@ -134,7 +157,9 @@ function loadConfig(env = process.env) {
|
|
|
134
157
|
maxResponseChars: num(
|
|
135
158
|
env.ELFA_MCP_MAX_RESPONSE_CHARS,
|
|
136
159
|
DEFAULT_MAX_RESPONSE_CHARS
|
|
137
|
-
)
|
|
160
|
+
),
|
|
161
|
+
auth,
|
|
162
|
+
oauth: auth === "oauth" ? oauthConfig(env) : void 0
|
|
138
163
|
};
|
|
139
164
|
}
|
|
140
165
|
|
|
@@ -148,7 +173,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
148
173
|
// package.json
|
|
149
174
|
var package_default = {
|
|
150
175
|
name: "@elfa-ai/mcp",
|
|
151
|
-
version: "
|
|
176
|
+
version: "4.2.0",
|
|
152
177
|
description: "Model Context Protocol server for the Elfa API - crypto social intelligence and the Auto condition engine",
|
|
153
178
|
license: "MIT",
|
|
154
179
|
author: "Elfa AI",
|
|
@@ -337,29 +362,6 @@ function pickDefined(input) {
|
|
|
337
362
|
}
|
|
338
363
|
return out;
|
|
339
364
|
}
|
|
340
|
-
var NOTIFICATION_ACTIONS = /* @__PURE__ */ new Set(["notify", "telegram_bot", "webhook"]);
|
|
341
|
-
function isRecord(value) {
|
|
342
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
343
|
-
}
|
|
344
|
-
function isNotificationAction(action) {
|
|
345
|
-
if (!isRecord(action)) return false;
|
|
346
|
-
if (typeof action.type !== "string") return false;
|
|
347
|
-
if (NOTIFICATION_ACTIONS.has(action.type)) return true;
|
|
348
|
-
if (action.type === "llm") {
|
|
349
|
-
if (!isRecord(action.params)) return false;
|
|
350
|
-
if (!isRecord(action.params.callback)) return false;
|
|
351
|
-
const callbackAction = action.params.callback.action;
|
|
352
|
-
if (!isRecord(callbackAction)) return false;
|
|
353
|
-
return typeof callbackAction.type === "string" && NOTIFICATION_ACTIONS.has(callbackAction.type);
|
|
354
|
-
}
|
|
355
|
-
return false;
|
|
356
|
-
}
|
|
357
|
-
function requiresSignature(query) {
|
|
358
|
-
if (!isRecord(query)) return true;
|
|
359
|
-
const actions = query.actions;
|
|
360
|
-
if (!Array.isArray(actions) || actions.length === 0) return true;
|
|
361
|
-
return !actions.every(isNotificationAction);
|
|
362
|
-
}
|
|
363
365
|
|
|
364
366
|
// src/tools/account.ts
|
|
365
367
|
function registerAccountStats(server, deps) {
|
|
@@ -367,7 +369,7 @@ function registerAccountStats(server, deps) {
|
|
|
367
369
|
"account_stats",
|
|
368
370
|
{
|
|
369
371
|
title: "Account stats",
|
|
370
|
-
description: "Smart follower and engagement stats for an X account. 1 credit per call. Use it to judge whether an account's reach is real before weighting what it posts.",
|
|
372
|
+
description: "Smart follower and engagement stats for an X account. 1 credit per call. Use it to judge whether an account's reach is real before weighting what it posts. Returns raw metrics, not a reputation score, so the signal is in the ratios. Legacy: it still works today and keeps working until 28 October 2026, when it will be removed, and it is not being extended before then. Build new work on mentions with mode=search, which returns the posting account alongside each post's engagement metrics.",
|
|
371
373
|
inputSchema: {
|
|
372
374
|
username: z2.string().describe("X username, with or without the @.")
|
|
373
375
|
},
|
|
@@ -486,9 +488,6 @@ function registerAutoDraft(server, deps) {
|
|
|
486
488
|
if (!args.query) {
|
|
487
489
|
return fail("method=upsert needs a query. Build one with auto_build.");
|
|
488
490
|
}
|
|
489
|
-
if (requiresSignature(args.query) && !deps.hasHmac) {
|
|
490
|
-
return fail(missingCredential("hmacSecret"));
|
|
491
|
-
}
|
|
492
491
|
}
|
|
493
492
|
const needsId = args.method === "get" || args.method === "delete" || args.method === "convert";
|
|
494
493
|
if (needsId && !args.draftId) {
|
|
@@ -656,7 +655,7 @@ function registerAutoQueryWrite(server, deps) {
|
|
|
656
655
|
"auto_query_write",
|
|
657
656
|
{
|
|
658
657
|
title: "Write Auto queries",
|
|
659
|
-
description: "Activate, cancel or delete an Auto query. Creating costs 5 credits plus LLM usage, cancel and delete are free. An activated query runs unattended and fires its action without asking again, so validate it with auto_validate and confirm the cost and the action with the user before calling this.
|
|
658
|
+
description: "Activate, cancel or delete an Auto query. Creating costs 5 credits plus LLM usage, cancel and delete are free. An activated query runs unattended and fires its action without asking again, so validate it with auto_validate and confirm the cost and the action with the user before calling this.",
|
|
660
659
|
inputSchema: {
|
|
661
660
|
method: z6.enum(["create", "cancel", "delete"]).describe(
|
|
662
661
|
"create activates a new query. cancel stops an active one. delete removes one that has already finished."
|
|
@@ -682,9 +681,6 @@ function registerAutoQueryWrite(server, deps) {
|
|
|
682
681
|
if (!args.query) {
|
|
683
682
|
return fail("method=create needs a query. Build one with auto_build.");
|
|
684
683
|
}
|
|
685
|
-
if (requiresSignature(args.query) && !deps.hasHmac) {
|
|
686
|
-
return fail(missingCredential("hmacSecret"));
|
|
687
|
-
}
|
|
688
684
|
} else if (!args.queryId) {
|
|
689
685
|
return fail(`method=${args.method} needs queryId. Find one with auto_query.`);
|
|
690
686
|
}
|
|
@@ -708,7 +704,7 @@ function registerAutoQueryWrite(server, deps) {
|
|
|
708
704
|
|
|
709
705
|
// src/tools/autoValidate.ts
|
|
710
706
|
import { z as z7 } from "zod";
|
|
711
|
-
var EXCHANGES = ["hyperliquid", "gmx", "binance"
|
|
707
|
+
var EXCHANGES = ["hyperliquid", "gmx", "binance"];
|
|
712
708
|
function registerAutoValidate(server, deps) {
|
|
713
709
|
server.registerTool(
|
|
714
710
|
"auto_validate",
|
|
@@ -869,7 +865,7 @@ function registerMentions(server, deps) {
|
|
|
869
865
|
"mentions",
|
|
870
866
|
{
|
|
871
867
|
title: "Mentions",
|
|
872
|
-
description: "Social mentions from X and Telegram. 1 credit per call. Returns engagement metrics and a link per post, not the post text. mode=top ranks one ticker's mentions by engagement. mode=search filters by keywords or by account. mode=news returns the token news feed.",
|
|
868
|
+
description: "Social mentions from X and Telegram. 1 credit per call. Returns engagement metrics and a link per post, not the post text. mode=top ranks one ticker's mentions by engagement. mode=search filters by keywords or by account. mode=news returns the token news feed, which is X posts from accounts tagged as news sources rather than articles from news outlets.",
|
|
873
869
|
inputSchema: {
|
|
874
870
|
mode: z9.enum(["top", "search", "news"]).describe(
|
|
875
871
|
"top needs ticker. search needs keywords or accountName. news is a feed and needs neither."
|
|
@@ -1116,7 +1112,6 @@ var REPORTED = [
|
|
|
1116
1112
|
"depositCredits",
|
|
1117
1113
|
"spendCapCredits",
|
|
1118
1114
|
"athenaEnabled",
|
|
1119
|
-
"hmacEnabled",
|
|
1120
1115
|
"scopes"
|
|
1121
1116
|
];
|
|
1122
1117
|
function summarise(data) {
|
|
@@ -1242,7 +1237,7 @@ var INSTRUCTIONS = `Elfa gives you crypto social intelligence plus Auto, a condi
|
|
|
1242
1237
|
|
|
1243
1238
|
Start cheap. trending, mentions and account_stats cost 1 credit. narratives costs 5 and market_chat costs more, so reach for them only when metrics are not enough. If a call fails on auth or credits, check api_status.
|
|
1244
1239
|
|
|
1245
|
-
Auto is a three step flow: auto_build drafts EQL from plain language, auto_validate checks it and returns the cost, auto_query_write activates it. Never activate without showing the user the estimated cost and the action that will fire.
|
|
1240
|
+
Auto is a three step flow: auto_build drafts EQL from plain language, auto_validate checks it and returns the cost, auto_query_write activates it. Never activate without showing the user the estimated cost and the action that will fire.
|
|
1246
1241
|
|
|
1247
1242
|
Auto has no push channel here. Poll auto_query with method=get and wait for pollAfterSeconds between calls.
|
|
1248
1243
|
|
|
@@ -1266,6 +1261,111 @@ function createServer(deps) {
|
|
|
1266
1261
|
return server;
|
|
1267
1262
|
}
|
|
1268
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
|
+
|
|
1269
1369
|
// src/http.ts
|
|
1270
1370
|
var MCP_PATH = "/mcp";
|
|
1271
1371
|
function header(req, name) {
|
|
@@ -1279,8 +1379,18 @@ function jsonRpcError(res, status, message) {
|
|
|
1279
1379
|
id: null
|
|
1280
1380
|
});
|
|
1281
1381
|
}
|
|
1282
|
-
function
|
|
1382
|
+
function defaultAllowedHosts(config) {
|
|
1383
|
+
const hosts = /* @__PURE__ */ new Set();
|
|
1384
|
+
for (const name of [config.host, "localhost", "127.0.0.1", "[::1]"]) {
|
|
1385
|
+
hosts.add(`${name}:${config.port}`);
|
|
1386
|
+
if (config.port === 80) hosts.add(name);
|
|
1387
|
+
}
|
|
1388
|
+
return [...hosts];
|
|
1389
|
+
}
|
|
1390
|
+
function createHttpApp(config, options = {}) {
|
|
1283
1391
|
const app = express();
|
|
1392
|
+
const oauth = config.oauth;
|
|
1393
|
+
const verifier = options.verifier ?? (oauth ? new TokenVerifier(oauth) : void 0);
|
|
1284
1394
|
app.disable("x-powered-by");
|
|
1285
1395
|
app.use(express.json({ limit: "4mb" }));
|
|
1286
1396
|
app.use(
|
|
@@ -1295,6 +1405,47 @@ function createHttpApp(config) {
|
|
|
1295
1405
|
app.get("/healthz", (_req, res) => {
|
|
1296
1406
|
res.json({ ok: true });
|
|
1297
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
|
+
}
|
|
1298
1449
|
app.post(MCP_PATH, async (req, res) => {
|
|
1299
1450
|
const origin = header(req, "origin");
|
|
1300
1451
|
if (origin && config.allowedOrigins.length > 0 && !config.allowedOrigins.includes(origin)) {
|
|
@@ -1304,13 +1455,14 @@ function createHttpApp(config) {
|
|
|
1304
1455
|
let server;
|
|
1305
1456
|
let transport;
|
|
1306
1457
|
try {
|
|
1307
|
-
const
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
});
|
|
1458
|
+
const apiKey = await resolveApiKey(req, res);
|
|
1459
|
+
if (apiKey === void 0) return;
|
|
1460
|
+
const deps = buildDeps(config, { apiKey: apiKey ?? void 0 });
|
|
1311
1461
|
server = createServer(deps);
|
|
1312
1462
|
transport = new StreamableHTTPServerTransport({
|
|
1313
|
-
|
|
1463
|
+
enableDnsRebindingProtection: true,
|
|
1464
|
+
allowedHosts: config.allowedHosts.length > 0 ? config.allowedHosts : defaultAllowedHosts(config),
|
|
1465
|
+
...config.allowedOrigins.length > 0 ? { allowedOrigins: config.allowedOrigins } : {}
|
|
1314
1466
|
});
|
|
1315
1467
|
res.on("close", () => {
|
|
1316
1468
|
void transport?.close();
|
package/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"server": {
|
|
3
3
|
"name": "elfa",
|
|
4
|
-
"version": "
|
|
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"
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
{
|
|
27
27
|
"name": "mentions",
|
|
28
28
|
"title": "Mentions",
|
|
29
|
-
"summary": "Social mentions from X and Telegram. mode=top ranks a ticker's mentions by engagement, mode=search filters by keyword or account, mode=news returns the token news feed.",
|
|
29
|
+
"summary": "Social mentions from X and Telegram. mode=top ranks a ticker's mentions by engagement, mode=search filters by keyword or account, mode=news returns the token news feed, which is X posts from accounts tagged as news sources rather than articles from news outlets.",
|
|
30
30
|
"credits": "1 per call",
|
|
31
31
|
"annotations": {
|
|
32
32
|
"readOnlyHint": true,
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
{
|
|
77
77
|
"name": "account_stats",
|
|
78
78
|
"title": "Account stats",
|
|
79
|
-
"summary": "Smart follower and engagement stats for an X account.",
|
|
79
|
+
"summary": "Smart follower and engagement stats for an X account. Legacy: it still works, but will be removed on 28 October 2026.",
|
|
80
80
|
"credits": "1 per call",
|
|
81
81
|
"annotations": {
|
|
82
82
|
"readOnlyHint": true,
|
|
@@ -207,21 +207,6 @@
|
|
|
207
207
|
"operationId": "auto-stream-query-v2",
|
|
208
208
|
"reason": "sse",
|
|
209
209
|
"note": "Long lived streams have no tool equivalent. Poll with auto_query."
|
|
210
|
-
},
|
|
211
|
-
{
|
|
212
|
-
"operationId": "auto-list-exchanges-v2",
|
|
213
|
-
"reason": "not-advertised",
|
|
214
|
-
"note": "Exchange connections are no longer part of the documented Auto surface."
|
|
215
|
-
},
|
|
216
|
-
{
|
|
217
|
-
"operationId": "auto-connect-exchange-v2",
|
|
218
|
-
"reason": "not-advertised",
|
|
219
|
-
"note": "Exchange connections are no longer part of the documented Auto surface."
|
|
220
|
-
},
|
|
221
|
-
{
|
|
222
|
-
"operationId": "auto-disconnect-exchange-v2",
|
|
223
|
-
"reason": "not-advertised",
|
|
224
|
-
"note": "Exchange connections are no longer part of the documented Auto surface."
|
|
225
210
|
}
|
|
226
211
|
]
|
|
227
212
|
}
|
package/package.json
CHANGED