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.
- package/package.json +1 -1
- package/src/sse-server.js +124 -2
package/package.json
CHANGED
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
|
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;
|