asrai-mcp 1.3.2 → 1.3.3

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/sse-server.js +124 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "asrai-mcp",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
4
4
  "description": "Asrai crypto analysis MCP server — pay-per-use via x402 on Base. Zero install: just npx.",
5
5
  "keywords": [
6
6
  "mcp",
package/src/sse-server.js CHANGED
@@ -19,7 +19,7 @@
19
19
  */
20
20
 
21
21
  import express from "express";
22
- import { randomBytes, randomUUID } from "node:crypto";
22
+ import { randomBytes, randomUUID, createHash } from "node:crypto";
23
23
  import { createRequire } from "node:module";
24
24
  import { privateKeyToAccount } from "viem/accounts";
25
25
 
@@ -32,6 +32,126 @@ import { connectionStorage } from "./tools.js";
32
32
 
33
33
  const app = express();
34
34
  app.use(express.json());
35
+ app.use(express.urlencoded({ extended: false }));
36
+
37
+ // CORS — required for browser-based MCP clients
38
+ app.use((req, res, next) => {
39
+ res.setHeader("Access-Control-Allow-Origin", "*");
40
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
41
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, mcp-session-id");
42
+ if (req.method === "OPTIONS") return res.status(204).end();
43
+ next();
44
+ });
45
+
46
+ const BASE_URL = process.env.ASRAI_BASE_URL ?? "https://mcp.asrai.me";
47
+
48
+ const escapeHtml = (s = "") =>
49
+ String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
50
+
51
+ // ── OAuth 2.1 + PKCE (for Grok and other web-based MCP clients) ──────────────
52
+
53
+ const authCodes = new Map();
54
+
55
+ function pruneExpiredCodes() {
56
+ const now = Date.now();
57
+ for (const [c, d] of authCodes) if (now > d.expiresAt) authCodes.delete(c);
58
+ }
59
+
60
+ app.get("/.well-known/oauth-authorization-server", (_req, res) => {
61
+ res.json({
62
+ issuer: BASE_URL,
63
+ authorization_endpoint: `${BASE_URL}/authorize`,
64
+ token_endpoint: `${BASE_URL}/token`,
65
+ response_types_supported: ["code"],
66
+ grant_types_supported: ["authorization_code"],
67
+ code_challenge_methods_supported: ["S256"],
68
+ token_endpoint_auth_methods_supported: ["none"],
69
+ });
70
+ });
71
+
72
+ const authPage = (params = {}, error = "") => `<!DOCTYPE html>
73
+ <html>
74
+ <head>
75
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
76
+ <title>Asrai — Connect to Grok</title>
77
+ <style>
78
+ *{box-sizing:border-box;margin:0;padding:0}
79
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#0f0f0f;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh}
80
+ .card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:2rem;width:100%;max-width:420px}
81
+ h1{font-size:1.2rem;font-weight:600;margin-bottom:.5rem}
82
+ .sub{color:#888;font-size:.875rem;margin-bottom:1.5rem;line-height:1.5}
83
+ label{display:block;font-size:.8rem;color:#aaa;margin-bottom:.375rem}
84
+ input{width:100%;padding:.625rem .75rem;background:#111;border:1px solid #333;border-radius:6px;color:#e0e0e0;font-size:.875rem;font-family:monospace}
85
+ input:focus{outline:none;border-color:#555}
86
+ button{margin-top:1rem;width:100%;padding:.625rem;background:#2563eb;border:none;border-radius:6px;color:#fff;font-size:.875rem;font-weight:500;cursor:pointer}
87
+ button:hover{background:#1d4ed8}
88
+ .error{color:#f87171;font-size:.8rem;margin-top:.75rem}
89
+ .hint{color:#555;font-size:.75rem;margin-top:.4rem}
90
+ .warn{background:#1c1208;border:1px solid #3d2800;border-radius:6px;padding:.75rem;font-size:.8rem;color:#f59e0b;margin-bottom:1rem;line-height:1.5}
91
+ </style>
92
+ </head>
93
+ <body><div class="card">
94
+ <h1>Connect Asrai x402 to Grok</h1>
95
+ <p class="warn">This server uses pay-per-use x402 payments on Base. Each tool call costs $0.005–$0.095 USDC from your wallet.</p>
96
+ <p class="sub">Enter your EVM wallet private key. Make sure it has USDC on Base.</p>
97
+ <form method="POST" action="/authorize">
98
+ ${Object.entries(params).map(([k,v]) => `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}">`).join("")}
99
+ <label for="key">Wallet Private Key</label>
100
+ <input type="text" id="key" name="key" placeholder="0x..." autocomplete="off" autofocus>
101
+ <p class="hint">Need a wallet? Use the /generate-wallet endpoint or any EVM wallet.</p>
102
+ ${error ? `<p class="error">${escapeHtml(error)}</p>` : ""}
103
+ <button type="submit">Connect</button>
104
+ </form>
105
+ </div></body></html>`;
106
+
107
+ app.get("/authorize", (req, res) => {
108
+ const { response_type, client_id, redirect_uri, code_challenge, code_challenge_method, state } = req.query;
109
+ if (response_type !== "code" || !redirect_uri || !code_challenge) {
110
+ return res.status(400).send("Missing required OAuth parameters.");
111
+ }
112
+ res.send(authPage({ response_type, client_id: client_id ?? "", redirect_uri, code_challenge, code_challenge_method: code_challenge_method ?? "S256", state: state ?? "" }));
113
+ });
114
+
115
+ app.post("/authorize", async (req, res) => {
116
+ const { response_type, client_id, redirect_uri, code_challenge, code_challenge_method, state, key } = req.body;
117
+ if (!redirect_uri || !code_challenge) return res.status(400).send("Missing required parameters.");
118
+
119
+ const privateKey = (key ?? "").trim();
120
+ const params = { response_type, client_id, redirect_uri, code_challenge, code_challenge_method, state };
121
+
122
+ if (!privateKey) return res.send(authPage(params, "Please enter your wallet private key."));
123
+
124
+ try {
125
+ privateKeyToAccount(privateKey); // validate format
126
+ } catch {
127
+ return res.send(authPage(params, "Invalid private key format. Must start with 0x followed by 64 hex characters."));
128
+ }
129
+
130
+ pruneExpiredCodes();
131
+ const code = randomUUID();
132
+ authCodes.set(code, { key: privateKey, codeChallenge: code_challenge, codeChallengeMethod: code_challenge_method ?? "S256", redirectUri: redirect_uri, expiresAt: Date.now() + 5 * 60 * 1000 });
133
+
134
+ const url = new URL(redirect_uri);
135
+ url.searchParams.set("code", code);
136
+ if (state) url.searchParams.set("state", state);
137
+ res.redirect(url.toString());
138
+ });
139
+
140
+ app.post("/token", (req, res) => {
141
+ const { grant_type, code, code_verifier } = req.body;
142
+ if (grant_type !== "authorization_code") return res.status(400).json({ error: "unsupported_grant_type" });
143
+
144
+ const stored = authCodes.get(code);
145
+ if (!stored || Date.now() > stored.expiresAt) return res.status(400).json({ error: "invalid_grant" });
146
+
147
+ if (stored.codeChallengeMethod === "S256") {
148
+ const expected = createHash("sha256").update(code_verifier ?? "").digest("base64url");
149
+ if (expected !== stored.codeChallenge) return res.status(400).json({ error: "invalid_grant", error_description: "PKCE verification failed" });
150
+ }
151
+
152
+ authCodes.delete(code);
153
+ res.json({ access_token: stored.key, token_type: "Bearer", expires_in: 3600 });
154
+ });
35
155
 
36
156
  // Track active SSE transports by session ID (for POST /messages routing)
37
157
  // Stores { transport, key } so the key is available when POST /messages arrives
@@ -43,7 +163,9 @@ const streamableTransports = {};
43
163
  // ── Key extraction helper ─────────────────────────────────────────────────────
44
164
 
45
165
  function extractKey(req, res, endpoint) {
46
- const key = (req.query.key ?? process.env.ASRAI_PRIVATE_KEY ?? "").trim();
166
+ const authHeader = req.headers["authorization"] ?? "";
167
+ const bearerKey = authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : "";
168
+ const key = (req.query.key ?? bearerKey || process.env.ASRAI_PRIVATE_KEY ?? "").trim();
47
169
  if (!key) {
48
170
  res.status(401).send(`Missing wallet key. Connect with: ${endpoint}?key=0x<private_key>`);
49
171
  return null;