@ellery/savio 0.1.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/dist/index.js +293 -0
- package/package.json +30 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/credentials.ts
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import { homedir } from "os";
|
|
10
|
+
var CREDENTIALS_DIR = join(homedir(), ".savio");
|
|
11
|
+
var CREDENTIALS_FILE = join(CREDENTIALS_DIR, "credentials.json");
|
|
12
|
+
function loadCredentials() {
|
|
13
|
+
try {
|
|
14
|
+
if (!existsSync(CREDENTIALS_FILE)) return null;
|
|
15
|
+
const raw = readFileSync(CREDENTIALS_FILE, "utf-8");
|
|
16
|
+
const parsed = JSON.parse(raw);
|
|
17
|
+
if (parsed.refreshToken && parsed.serverUrl) {
|
|
18
|
+
return parsed;
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function saveCredentials(creds) {
|
|
26
|
+
if (!existsSync(CREDENTIALS_DIR)) {
|
|
27
|
+
mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 448 });
|
|
28
|
+
}
|
|
29
|
+
writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), { mode: 384 });
|
|
30
|
+
}
|
|
31
|
+
function clearCredentials() {
|
|
32
|
+
try {
|
|
33
|
+
if (existsSync(CREDENTIALS_FILE)) {
|
|
34
|
+
unlinkSync(CREDENTIALS_FILE);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/auth.ts
|
|
44
|
+
var TokenManager = class {
|
|
45
|
+
constructor(refreshToken, serverUrl) {
|
|
46
|
+
this.refreshToken = refreshToken;
|
|
47
|
+
this.serverUrl = serverUrl;
|
|
48
|
+
}
|
|
49
|
+
refreshToken;
|
|
50
|
+
serverUrl;
|
|
51
|
+
accessToken = null;
|
|
52
|
+
expiresAt = 0;
|
|
53
|
+
cachedUser = null;
|
|
54
|
+
async getAccessToken() {
|
|
55
|
+
if (this.accessToken && Date.now() < this.expiresAt - 6e4) {
|
|
56
|
+
return this.accessToken;
|
|
57
|
+
}
|
|
58
|
+
const res = await fetch(`${this.serverUrl}/auth/cli/token`, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: { "Content-Type": "application/json" },
|
|
61
|
+
body: JSON.stringify({ refreshToken: this.refreshToken })
|
|
62
|
+
});
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
const body = await res.json().catch(() => ({ error: "Unknown error" }));
|
|
65
|
+
const msg = body.error || `HTTP ${res.status}`;
|
|
66
|
+
throw new TokenRefreshError(msg);
|
|
67
|
+
}
|
|
68
|
+
const data = await res.json();
|
|
69
|
+
this.accessToken = data.accessToken;
|
|
70
|
+
this.expiresAt = Date.now() + data.expiresIn * 1e3;
|
|
71
|
+
this.cachedUser = data.user;
|
|
72
|
+
return data.accessToken;
|
|
73
|
+
}
|
|
74
|
+
getUser() {
|
|
75
|
+
return this.cachedUser;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
var TokenRefreshError = class extends Error {
|
|
79
|
+
constructor(message) {
|
|
80
|
+
super(message);
|
|
81
|
+
this.name = "TokenRefreshError";
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// src/login.ts
|
|
86
|
+
import { createServer } from "http";
|
|
87
|
+
import { randomBytes } from "crypto";
|
|
88
|
+
import { URL as URL2 } from "url";
|
|
89
|
+
import open from "open";
|
|
90
|
+
var LOGIN_TIMEOUT_MS = 12e4;
|
|
91
|
+
async function browserLogin(serverUrl) {
|
|
92
|
+
const state = randomBytes(32).toString("hex");
|
|
93
|
+
const { port, token } = await startCallbackServer(state);
|
|
94
|
+
const authUrl = `${serverUrl}/cli-auth?port=${port}&state=${encodeURIComponent(state)}`;
|
|
95
|
+
process.stderr.write(`Opening browser to authorize...
|
|
96
|
+
`);
|
|
97
|
+
process.stderr.write(`If the browser doesn't open, visit:
|
|
98
|
+
${authUrl}
|
|
99
|
+
|
|
100
|
+
`);
|
|
101
|
+
await open(authUrl);
|
|
102
|
+
const refreshToken = await token;
|
|
103
|
+
saveCredentials({ refreshToken, serverUrl });
|
|
104
|
+
process.stderr.write(`Authenticated successfully. Credentials saved.
|
|
105
|
+
`);
|
|
106
|
+
}
|
|
107
|
+
function startCallbackServer(expectedState) {
|
|
108
|
+
return new Promise((resolveSetup) => {
|
|
109
|
+
let resolveToken;
|
|
110
|
+
let rejectToken;
|
|
111
|
+
const tokenPromise = new Promise((resolve, reject) => {
|
|
112
|
+
resolveToken = resolve;
|
|
113
|
+
rejectToken = reject;
|
|
114
|
+
});
|
|
115
|
+
const server = createServer((req, res) => {
|
|
116
|
+
const url = new URL2(req.url, `http://localhost`);
|
|
117
|
+
if (url.pathname === "/callback") {
|
|
118
|
+
const token = url.searchParams.get("token");
|
|
119
|
+
const state = url.searchParams.get("state");
|
|
120
|
+
if (state !== expectedState) {
|
|
121
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
122
|
+
res.end("<h2>State mismatch</h2><p>Authorization failed. Please try again.</p>");
|
|
123
|
+
rejectToken(new Error("State mismatch \u2014 possible CSRF"));
|
|
124
|
+
server.close();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (!token) {
|
|
128
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
129
|
+
res.end("<h2>Missing token</h2><p>Authorization failed. Please try again.</p>");
|
|
130
|
+
rejectToken(new Error("No token in callback"));
|
|
131
|
+
server.close();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
135
|
+
res.end(
|
|
136
|
+
"<h2>Authorized</h2><p>You can close this tab and return to your terminal.</p><script>window.close()</script>"
|
|
137
|
+
);
|
|
138
|
+
resolveToken(token);
|
|
139
|
+
server.close();
|
|
140
|
+
} else {
|
|
141
|
+
res.writeHead(404);
|
|
142
|
+
res.end("Not found");
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
server.listen(0, "127.0.0.1", () => {
|
|
146
|
+
const addr = server.address();
|
|
147
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
148
|
+
resolveSetup({ port, token: tokenPromise });
|
|
149
|
+
});
|
|
150
|
+
const timeout = setTimeout(() => {
|
|
151
|
+
rejectToken(new Error("Login timed out after 2 minutes"));
|
|
152
|
+
server.close();
|
|
153
|
+
}, LOGIN_TIMEOUT_MS);
|
|
154
|
+
tokenPromise.then(() => clearTimeout(timeout)).catch(() => clearTimeout(timeout));
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// src/proxy.ts
|
|
159
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
160
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
161
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
162
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
163
|
+
import {
|
|
164
|
+
ListToolsRequestSchema,
|
|
165
|
+
CallToolRequestSchema,
|
|
166
|
+
ListResourcesRequestSchema,
|
|
167
|
+
ReadResourceRequestSchema,
|
|
168
|
+
ListResourceTemplatesRequestSchema
|
|
169
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
170
|
+
async function startProxy(refreshToken, serverUrl) {
|
|
171
|
+
const tokenManager = new TokenManager(refreshToken, serverUrl);
|
|
172
|
+
try {
|
|
173
|
+
await tokenManager.getAccessToken();
|
|
174
|
+
} catch (err) {
|
|
175
|
+
if (err instanceof TokenRefreshError) {
|
|
176
|
+
process.stderr.write(`Authentication failed: ${err.message}
|
|
177
|
+
`);
|
|
178
|
+
process.stderr.write(`Run: npx @ellery/savio login --server ${serverUrl}
|
|
179
|
+
`);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
184
|
+
const httpTransport = new StreamableHTTPClientTransport(
|
|
185
|
+
new URL(`${serverUrl}/mcp`),
|
|
186
|
+
{
|
|
187
|
+
fetch: async (input, init) => {
|
|
188
|
+
const token = await tokenManager.getAccessToken();
|
|
189
|
+
const headers = new Headers(init?.headers);
|
|
190
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
191
|
+
return globalThis.fetch(input, { ...init, headers });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
);
|
|
195
|
+
const client = new Client({ name: "savio", version: "1.0.0" });
|
|
196
|
+
await client.connect(httpTransport);
|
|
197
|
+
process.stderr.write(`Connected to ${serverUrl}
|
|
198
|
+
`);
|
|
199
|
+
const server = new Server(
|
|
200
|
+
{ name: "savio", version: "1.0.0" },
|
|
201
|
+
{ capabilities: { tools: {}, resources: {} } }
|
|
202
|
+
);
|
|
203
|
+
server.setRequestHandler(ListToolsRequestSchema, async (req) => {
|
|
204
|
+
return client.listTools(req.params);
|
|
205
|
+
});
|
|
206
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
207
|
+
return client.callTool(req.params);
|
|
208
|
+
});
|
|
209
|
+
server.setRequestHandler(ListResourcesRequestSchema, async (req) => {
|
|
210
|
+
return client.listResources(req.params);
|
|
211
|
+
});
|
|
212
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
|
|
213
|
+
return client.readResource(req.params);
|
|
214
|
+
});
|
|
215
|
+
server.setRequestHandler(ListResourceTemplatesRequestSchema, async (req) => {
|
|
216
|
+
return client.listResourceTemplates(req.params);
|
|
217
|
+
});
|
|
218
|
+
const stdioTransport = new StdioServerTransport();
|
|
219
|
+
await server.connect(stdioTransport);
|
|
220
|
+
process.stderr.write(`MCP proxy ready (stdio)
|
|
221
|
+
`);
|
|
222
|
+
const shutdown = async () => {
|
|
223
|
+
process.stderr.write(`Shutting down...
|
|
224
|
+
`);
|
|
225
|
+
await server.close();
|
|
226
|
+
await client.close();
|
|
227
|
+
process.exit(0);
|
|
228
|
+
};
|
|
229
|
+
process.on("SIGINT", shutdown);
|
|
230
|
+
process.on("SIGTERM", shutdown);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// src/index.ts
|
|
234
|
+
var DEFAULT_SERVER = "https://app.savio.so";
|
|
235
|
+
var program = new Command().name("savio").description("Savio MCP proxy \u2014 browser auth for Claude Code").version("0.1.0");
|
|
236
|
+
program.command("proxy", { isDefault: true, hidden: true }).description("Start the MCP stdio proxy (default command)").action(async () => {
|
|
237
|
+
const creds = loadCredentials();
|
|
238
|
+
if (!creds) {
|
|
239
|
+
process.stderr.write("Not authenticated. Run: npx @ellery/savio login\n");
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
await startProxy(creds.refreshToken, creds.serverUrl);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
process.stderr.write(`Proxy error: ${err instanceof Error ? err.message : err}
|
|
246
|
+
`);
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
program.command("login").description("Authenticate via browser").option("--server <url>", "Savio server URL", DEFAULT_SERVER).action(async (opts) => {
|
|
251
|
+
try {
|
|
252
|
+
await browserLogin(opts.server);
|
|
253
|
+
} catch (err) {
|
|
254
|
+
process.stderr.write(`Login failed: ${err instanceof Error ? err.message : err}
|
|
255
|
+
`);
|
|
256
|
+
process.exit(1);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
program.command("logout").description("Clear stored credentials").action(() => {
|
|
260
|
+
if (clearCredentials()) {
|
|
261
|
+
process.stderr.write("Credentials cleared.\n");
|
|
262
|
+
} else {
|
|
263
|
+
process.stderr.write("No credentials stored.\n");
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
program.command("whoami").description("Show current user info").action(async () => {
|
|
267
|
+
const creds = loadCredentials();
|
|
268
|
+
if (!creds) {
|
|
269
|
+
process.stderr.write("Not authenticated. Run: npx @ellery/savio login\n");
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
const tokenManager = new TokenManager(creds.refreshToken, creds.serverUrl);
|
|
273
|
+
try {
|
|
274
|
+
await tokenManager.getAccessToken();
|
|
275
|
+
const user = tokenManager.getUser();
|
|
276
|
+
if (user) {
|
|
277
|
+
process.stderr.write(`${user.name} (${user.email})
|
|
278
|
+
`);
|
|
279
|
+
process.stderr.write(`Server: ${creds.serverUrl}
|
|
280
|
+
`);
|
|
281
|
+
}
|
|
282
|
+
} catch (err) {
|
|
283
|
+
if (err instanceof TokenRefreshError) {
|
|
284
|
+
process.stderr.write(`Token expired or revoked: ${err.message}
|
|
285
|
+
`);
|
|
286
|
+
process.stderr.write(`Run: npx @ellery/savio login --server ${creds.serverUrl}
|
|
287
|
+
`);
|
|
288
|
+
process.exit(1);
|
|
289
|
+
}
|
|
290
|
+
throw err;
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ellery/savio",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Savio MCP proxy — browser auth, local stdio transport for Claude Code",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"savio": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsup",
|
|
14
|
+
"dev": "tsup --watch",
|
|
15
|
+
"typecheck": "tsc --noEmit"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
19
|
+
"commander": "^12.1.0",
|
|
20
|
+
"open": "^10.1.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"tsup": "^8.3.0",
|
|
24
|
+
"typescript": "^5.7.0"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18.0.0"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT"
|
|
30
|
+
}
|