@memoket-ai/cli 2.0.1

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/src/gateway.js ADDED
@@ -0,0 +1,128 @@
1
+ // 网关调用层:tools/call 走新多入口网关(/v1/tools),鉴权用 mtok(由 userToken 现签+缓存)。
2
+
3
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
4
+ import { apiBaseFromMCPURL } from "./http-integration.js";
5
+ import { authPath, loadUserToken } from "./auth-basic.js";
6
+ import { ensureToken } from "./oauth.js";
7
+
8
+ // 默认 scope 集(与后端一致,webhooks:write 不默认给)。
9
+ const DEFAULT_SCOPES = [
10
+ "recordings:read",
11
+ "summaries:read",
12
+ "transcripts:read",
13
+ "tasks:read",
14
+ "search:read",
15
+ ];
16
+
17
+ async function readAuth() {
18
+ try {
19
+ return JSON.parse(await readFile(await authPath(), "utf8"));
20
+ } catch {
21
+ return {};
22
+ }
23
+ }
24
+
25
+ async function writeAuth(obj) {
26
+ const p = await authPath();
27
+ await mkdir(p.replace(/[\\/][^\\/]*$/, ""), { recursive: true });
28
+ await writeFile(p, JSON.stringify(obj, null, 2), { mode: 0o600 });
29
+ }
30
+
31
+ // issueApiToken 用 userToken 换一枚 mtok 并缓存到 auth.json(供 CLI 自身 call 复用)。
32
+ // 鉴权优先用密码登录的 userToken;没有时回退浏览器 OAuth 的 access_token
33
+ // (后端 /v1/api-tokens 两者都认)。两者都没有才提示登录。
34
+ export async function issueApiToken(mcpURL, scopes = DEFAULT_SCOPES) {
35
+ const userTok = await loadUserToken();
36
+ const oauthTok = await ensureToken(mcpURL);
37
+ if (!userTok && !oauthTok) throw new Error("not logged in — run `memoket login` first");
38
+
39
+ const mint = async (bearer) => {
40
+ const res = await fetch(`${apiBaseFromMCPURL(mcpURL)}/v1/api-tokens`, {
41
+ method: "POST",
42
+ headers: { Authorization: `Bearer ${bearer}`, "Content-Type": "application/json" },
43
+ body: JSON.stringify({ scopes }),
44
+ signal: AbortSignal.timeout(30_000),
45
+ });
46
+ const text = await res.text();
47
+ let data = {};
48
+ try {
49
+ data = JSON.parse(text);
50
+ } catch {
51
+ /* non-json */
52
+ }
53
+ return { res, data, text };
54
+ };
55
+
56
+ // userToken TTL 仅 1 小时;过期(401)时自动回退浏览器 OAuth token 重试,避免旧 userToken 顶死。
57
+ let { res, data, text } = await mint(userTok || oauthTok);
58
+ if (res.status === 401 && userTok && oauthTok && oauthTok !== userTok) {
59
+ ({ res, data, text } = await mint(oauthTok));
60
+ }
61
+ if (!res.ok || !data.token) {
62
+ if (res.status === 401) {
63
+ throw new Error(`session expired or invalid — run \`memoket login\` again (server: ${data?.error?.message || text})`);
64
+ }
65
+ throw new Error(`token issue failed (${res.status}): ${data?.error?.message || text}`);
66
+ }
67
+ const auth = await readAuth();
68
+ auth.apiToken = data.token;
69
+ // Track which endpoint this mtok was minted for. ensureApiToken()
70
+ // invalidates the cached mtok on endpoint mismatch (e.g., after
71
+ // `memoket endpoints use <other>`), forcing a re-mint.
72
+ auth.apiTokenEndpoint = mcpURL;
73
+ await writeAuth(auth);
74
+ return data.token;
75
+ }
76
+
77
+ // ensureApiToken:环境变量 > 缓存(endpoint 匹配)> 用 userToken 现签。
78
+ // Cached mtok 不匹配当前 active endpoint 时清掉重签。
79
+ export async function ensureApiToken(mcpURL) {
80
+ if (process.env.MEMOKET_API_TOKEN) return process.env.MEMOKET_API_TOKEN;
81
+ const auth = await readAuth();
82
+ if (auth.apiToken) {
83
+ if (auth.apiTokenEndpoint === mcpURL) return auth.apiToken;
84
+ // endpoint mismatch — drop stale mtok, re-mint below
85
+ delete auth.apiToken;
86
+ delete auth.apiTokenEndpoint;
87
+ await writeAuth(auth);
88
+ }
89
+ return issueApiToken(mcpURL);
90
+ }
91
+
92
+ // listGatewayTools GET /v1/tools(免鉴权)。
93
+ export async function listGatewayTools(mcpURL) {
94
+ const res = await fetch(`${apiBaseFromMCPURL(mcpURL)}/v1/tools`, {
95
+ signal: AbortSignal.timeout(30_000),
96
+ });
97
+ if (!res.ok) throw new Error(`list tools failed (${res.status})`);
98
+ const data = await res.json();
99
+ return data.tools || [];
100
+ }
101
+
102
+ // getGatewayTool 取单个 tool 的完整定义(含 input_schema);免鉴权,复用 listGatewayTools。
103
+ export async function getGatewayTool(mcpURL, name) {
104
+ const tools = await listGatewayTools(mcpURL);
105
+ return tools.find((t) => t.name === name) || null;
106
+ }
107
+
108
+ // callGatewayTool POST /v1/tools/{name}(Bearer mtok)。
109
+ export async function callGatewayTool(mcpURL, name, args) {
110
+ const mtok = await ensureApiToken(mcpURL);
111
+ const res = await fetch(`${apiBaseFromMCPURL(mcpURL)}/v1/tools/${encodeURIComponent(name)}`, {
112
+ method: "POST",
113
+ headers: { Authorization: `Bearer ${mtok}`, "Content-Type": "application/json" },
114
+ body: JSON.stringify(args || {}),
115
+ signal: AbortSignal.timeout(60_000),
116
+ });
117
+ const text = await res.text();
118
+ let data = null;
119
+ try {
120
+ data = JSON.parse(text);
121
+ } catch {
122
+ data = { raw: text };
123
+ }
124
+ if (!res.ok) {
125
+ throw new Error(`call ${name} failed (${res.status}): ${data?.error?.message || text}`);
126
+ }
127
+ return data;
128
+ }
@@ -0,0 +1,155 @@
1
+ // REST helpers for Personal API Tokens.
2
+ // These commands target the planned Gateway API surface. They intentionally do
3
+ // not special-case n8n/Make/Zapier; those tools use the same HTTP primitives.
4
+
5
+ import { ensureToken } from "./oauth.js";
6
+ import { loadUserToken } from "./auth-basic.js";
7
+
8
+ const DEFAULT_API_SCOPES = [
9
+ "recordings:read",
10
+ "summaries:read",
11
+ "transcripts:read",
12
+ "search:read",
13
+ ];
14
+
15
+ // apiBaseFromMCPURL returns the API base URL for the current build.
16
+ // The base is baked into the build via config/<env>.json#apiBase (stamped
17
+ // into src/config.local.js by scripts/build.js). Two escape hatches:
18
+ // - process.env.MEMOKET_API_URL / MEMOKET_GATEWAY_URL override at runtime
19
+ // - hardcoded fallback in config.js (prod) when config.local.js is missing
20
+ // The mcpURL arg is kept for backward compatibility with callers but is
21
+ // not used to derive the base — the base is build-time, not URL-derived.
22
+ import { apiBase } from "./config.js";
23
+
24
+ export function apiBaseFromMCPURL(_mcpURL) {
25
+ const explicit = process.env.MEMOKET_API_URL || process.env.MEMOKET_GATEWAY_URL;
26
+ if (explicit) return explicit.replace(/\/$/, "");
27
+ return apiBase;
28
+ }
29
+
30
+ function parseCSV(values) {
31
+ return (values || [])
32
+ .flatMap((v) => String(v || "").split(","))
33
+ .map((v) => v.trim())
34
+ .filter(Boolean);
35
+ }
36
+
37
+ export function parseScopes(values, fallback = DEFAULT_API_SCOPES) {
38
+ const scopes = parseCSV(values);
39
+ return scopes.length ? scopes : [...fallback];
40
+ }
41
+
42
+ export async function gatewayRequest(mcpURL, path, { method = "GET", body } = {}) {
43
+ const apiBase = apiBaseFromMCPURL(mcpURL);
44
+
45
+ const send = async (token) => {
46
+ const res = await fetch(`${apiBase}${path}`, {
47
+ method,
48
+ headers: {
49
+ Authorization: `Bearer ${token}`,
50
+ Accept: "application/json",
51
+ ...(body === undefined ? {} : { "Content-Type": "application/json" }),
52
+ },
53
+ body: body === undefined ? undefined : JSON.stringify(body),
54
+ signal: AbortSignal.timeout(30_000),
55
+ });
56
+ const text = await res.text();
57
+ let data = null;
58
+ if (text) {
59
+ try {
60
+ data = JSON.parse(text);
61
+ } catch {
62
+ data = { raw: text };
63
+ }
64
+ }
65
+ return { res, data, text };
66
+ };
67
+
68
+ // 与 gateway.js 的 call 路对齐:先用邮箱密码登录的 userToken,没有再用浏览器 OAuth token。
69
+ // 后端 /v1/api-tokens 两者都认(oauthserver combinedResolver)。
70
+ const userTok = await loadUserToken();
71
+ const oauthTok = await ensureToken(mcpURL);
72
+ if (!userTok && !oauthTok) {
73
+ throw new Error("not logged in — run `memoket login` first");
74
+ }
75
+
76
+ // userToken TTL 仅 1 小时;过期/失效时后端回 401 "invalid user token"。
77
+ // 若手上还有浏览器 OAuth token,则自动回退重试,避免 auth.json 里的旧 userToken 把 api-tokens 路顶死。
78
+ let { res, data, text } = await send(userTok || oauthTok);
79
+ if (res.status === 401 && userTok && oauthTok && oauthTok !== userTok) {
80
+ ({ res, data, text } = await send(oauthTok));
81
+ }
82
+
83
+ if (res.status < 200 || res.status >= 300) {
84
+ if (res.status === 401) {
85
+ const raw = data?.error?.message || data?.message || text || res.statusText;
86
+ throw new Error(`session expired or invalid — run \`memoket login\` again (server: ${raw})`);
87
+ }
88
+ const detail = data?.error?.message || data?.message || data?.detail || data?.raw || res.statusText;
89
+ throw new Error(`${method} ${path} failed (${res.status}): ${detail}`);
90
+ }
91
+ return data || {};
92
+ }
93
+
94
+ function printShownOnce(label, value) {
95
+ if (!value) return;
96
+ console.log(`${label}:`);
97
+ console.log(value);
98
+ console.log();
99
+ }
100
+
101
+ export async function createAPIToken(mcpURL, opts = {}) {
102
+ const name = opts.name || "HTTP integration";
103
+ const scopes = parseScopes(opts.scope);
104
+ const expiresInDays = Number(opts.expiresIn || opts.expires || 90);
105
+
106
+ const payload = {
107
+ name,
108
+ scopes,
109
+ expires_in_days: expiresInDays,
110
+ };
111
+ const out = await gatewayRequest(mcpURL, "/v1/api-tokens", { method: "POST", body: payload });
112
+ const token = out.token || out.data?.token;
113
+
114
+ console.log("API token created.");
115
+ console.log(`Name: ${out.name || out.data?.name || name}`);
116
+ console.log(`Scopes: ${(out.scopes || out.data?.scopes || scopes).join(", ")}`);
117
+ const expiresAt = out.expires_at || out.data?.expires_at;
118
+ if (expiresAt) console.log(`Expires: ${expiresAt}`);
119
+ console.log();
120
+ printShownOnce("API Token (shown once)", token);
121
+ console.log("Use it as:");
122
+ console.log("Authorization: Bearer " + (token || "<mk_pat_...>"));
123
+ return out;
124
+ }
125
+
126
+ export async function listAPITokens(mcpURL) {
127
+ const out = await gatewayRequest(mcpURL, "/v1/api-tokens");
128
+ console.log(JSON.stringify(out, null, 2));
129
+ }
130
+
131
+ export async function revokeAPIToken(mcpURL, id) {
132
+ if (!id) throw new Error("token id is required");
133
+ await gatewayRequest(mcpURL, `/v1/api-tokens/${encodeURIComponent(id)}`, { method: "DELETE" });
134
+ console.log(`API token revoked: ${id}`);
135
+ }
136
+
137
+ // rotateAPIToken 旧换新:后端在事务里用旧 token 的 user+scopes 签发新 mtok,
138
+ // 旧的置为 revoked,返回 { token: mtok_live_... }。新 token 一次性打印,旧 id 失效。
139
+ export async function rotateAPIToken(mcpURL, id) {
140
+ if (!id) throw new Error("token id is required");
141
+ const out = await gatewayRequest(mcpURL, `/v1/api-tokens/${encodeURIComponent(id)}/rotate`, {
142
+ method: "POST",
143
+ });
144
+ const token = out.token || out.data?.token;
145
+
146
+ console.log("API token rotated.");
147
+ console.log();
148
+ printShownOnce("New API Token (shown once)", token);
149
+ console.log("Use it as:");
150
+ console.log("Authorization: Bearer " + (token || "<mtok_live_...>"));
151
+ console.log();
152
+ console.log(`旧 token ${id} 已失效。`);
153
+ return out;
154
+ }
155
+
@@ -0,0 +1,134 @@
1
+ // Minimal streamable-HTTP MCP client (initialize / tools/list / tools/call).
2
+ // The server replies as SSE (event: message\ndata: {json}) or plain JSON; both
3
+ // are handled. The tool surface is intentionally NOT modeled here — the CLI is a
4
+ // passthrough so it stays consistent with whatever the live server advertises.
5
+
6
+ import { protocolVersion, clientName, version as cliVersion } from "./config.js";
7
+
8
+ // All network calls share this timeout. Some MCP servers can take >30s to
9
+ // respond to well-known discovery under load; bail out instead of hanging
10
+ // the CLI.
11
+ const FETCH_TIMEOUT_MS = 30_000;
12
+
13
+ function withTimeout(opts = {}) {
14
+ return { ...opts, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) };
15
+ }
16
+
17
+ export class RPCError extends Error {
18
+ constructor(code, message) {
19
+ super(`rpc error ${code}: ${message}`);
20
+ this.code = code;
21
+ }
22
+ }
23
+
24
+ async function postRPC(endpoint, { token, sessionID, payload }) {
25
+ const headers = {
26
+ "Content-Type": "application/json",
27
+ Accept: "application/json, text/event-stream",
28
+ };
29
+ if (token) headers["Authorization"] = `Bearer ${token}`;
30
+ if (sessionID) {
31
+ headers["Mcp-Session-Id"] = sessionID;
32
+ headers["MCP-Protocol-Version"] = protocolVersion;
33
+ }
34
+ const res = await fetch(endpoint, {
35
+ method: "POST",
36
+ headers,
37
+ body: JSON.stringify(payload),
38
+ ...withTimeout(),
39
+ });
40
+ if (res.status === 401) {
41
+ const err = new Error("unauthorized (401) — please run `memoket login` first");
42
+ err.response = res;
43
+ throw err;
44
+ }
45
+ const raw = Buffer.from(await res.arrayBuffer());
46
+ if (res.status === 202 || raw.length === 0) {
47
+ return { resp: null, headers: res.headers };
48
+ }
49
+ const resp = parseRPC(raw, res.headers.get("content-type") || "");
50
+ if (!resp) return { resp: null, headers: res.headers };
51
+ if (resp.error) throw new RPCError(resp.error.code, resp.error.message);
52
+ return { resp, headers: res.headers };
53
+ }
54
+
55
+ function parseRPC(raw, contentType) {
56
+ const trimmed = raw.toString("utf8").trim();
57
+ if (!trimmed) return null;
58
+ const isSSE =
59
+ contentType.includes("text/event-stream") ||
60
+ trimmed.startsWith("event:") ||
61
+ trimmed.startsWith("data:");
62
+ let body = trimmed;
63
+ if (isSSE) {
64
+ let payload = "";
65
+ for (const line of trimmed.split("\n")) {
66
+ const l = line.endsWith("\r") ? line.slice(0, -1) : line;
67
+ if (l.startsWith("data:")) {
68
+ payload = l.slice("data:".length).trim();
69
+ }
70
+ }
71
+ if (!payload) throw new Error("empty SSE response");
72
+ body = payload;
73
+ }
74
+ try {
75
+ return JSON.parse(body);
76
+ } catch (e) {
77
+ throw new Error(`decode response: ${e.message}`);
78
+ }
79
+ }
80
+
81
+ export async function connect(endpoint, token) {
82
+ const { resp, headers } = await postRPC(endpoint, {
83
+ token,
84
+ payload: {
85
+ jsonrpc: "2.0",
86
+ id: 1,
87
+ method: "initialize",
88
+ params: {
89
+ protocolVersion,
90
+ capabilities: {},
91
+ clientInfo: { name: clientName, version: cliVersion },
92
+ },
93
+ },
94
+ });
95
+ if (!resp) throw new Error("empty initialize response");
96
+ const sessionID = headers.get("mcp-session-id") || "";
97
+ const info = resp.result || {};
98
+ // Best-effort initialized notification; server returns 202 with no body.
99
+ try {
100
+ await postRPC(endpoint, {
101
+ token,
102
+ sessionID,
103
+ payload: { jsonrpc: "2.0", method: "notifications/initialized" },
104
+ });
105
+ } catch {
106
+ /* ignore */
107
+ }
108
+ return { endpoint, token, id: sessionID, info };
109
+ }
110
+
111
+ export async function listTools(session) {
112
+ const { resp } = await postRPC(session.endpoint, {
113
+ token: session.token,
114
+ sessionID: session.id,
115
+ payload: { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} },
116
+ });
117
+ if (!resp) throw new Error("empty tools/list response");
118
+ return resp.result?.tools || [];
119
+ }
120
+
121
+ export async function callTool(session, name, args) {
122
+ const { resp } = await postRPC(session.endpoint, {
123
+ token: session.token,
124
+ sessionID: session.id,
125
+ payload: {
126
+ jsonrpc: "2.0",
127
+ id: 3,
128
+ method: "tools/call",
129
+ params: { name, arguments: args },
130
+ },
131
+ });
132
+ if (!resp) throw new Error("empty tools/call response");
133
+ return resp.result;
134
+ }