@zegazone_mcp/mcp 2.0.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/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # @zegazone_mcp/mcp
2
+
3
+ MCP wrapper over `POST /functions/v1/thirdparty-v1`.
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ npx @zegazone_mcp/mcp --pair
9
+ ```
10
+
11
+ One-time OAuth pairing stores credentials in `~/.zegazone-mcp/credentials.json`. Access tokens refresh automatically — you never handle tokens manually.
12
+
13
+ ## MCP client config
14
+
15
+ **Claude Desktop / Cursor:**
16
+
17
+ ```json
18
+ {
19
+ "mcpServers": {
20
+ "zegazone": {
21
+ "command": "npx",
22
+ "args": ["@zegazone_mcp/mcp"]
23
+ }
24
+ }
25
+ }
26
+ ```
27
+
28
+ Set `ZEGA_API_BASE=https://api.zegaphone.com` in env if needed (defaults apply for production).
29
+
30
+ ## What this server does
31
+
32
+ - Exposes stable MCP tools (`collections_delete`, `media_move`, etc.) mapped to API `op` values.
33
+ - Keeps API as source of truth.
34
+ - Applies safety defaults for destructive operations (`dry_run` when neither `confirm` nor `dry_run` is provided).
35
+ - Adds `idempotency_key` automatically to mutating operations when absent.
36
+
37
+ ## Tool mapping
38
+
39
+ Examples:
40
+
41
+ - `operations_list` -> `operations.list`
42
+ - `collections_delete` -> `collections.delete`
43
+ - `media_delete` -> `media.delete`
44
+ - `media_move` -> `media.move`
45
+ - `media_reorder` -> `media.reorder`
46
+
47
+ Also includes generic `thirdparty_call` for forward compatibility.
48
+
49
+ ## Configuration
50
+
51
+ Copy `.env.example` to `.env.local`. You need `ZEGA_API_BASE` plus **either** a one-time OAuth pairing **or** a static access token.
52
+
53
+ **OAuth (recommended):** stores refresh + access metadata in `~/.zegazone-mcp/credentials.json`. The server refreshes access tokens automatically.
54
+
55
+ ```bash
56
+ export ZEGA_API_BASE=https://api.zegaphone.com
57
+ npx @zegazone_mcp/mcp --pair
58
+ ```
59
+
60
+ **Legacy:** set `ZEGA_ACCESS_TOKEN` to a short-lived JWT (see `supabase/docs/OPENCLAW_ZEGAZONE.md`).
61
+
62
+ Optional: `ZEGA_APP_BASE` (defaults to `https://www.zegazone.com`), `ZEGA_OAUTH_CLIENT_ID`, `ZEGA_CREDENTIALS_PATH`, `ZEGA_DEFAULT_DRY_RUN`, `ZEGA_TIMEOUT_MS`.
63
+
64
+ ## Local development
65
+
66
+ Clone the repo and work from `supabase/mcp-zegazone-thirdparty/`:
67
+
68
+ ```bash
69
+ npm install
70
+ npm run dev
71
+ ```
72
+
73
+ PowerShell helper:
74
+
75
+ ```powershell
76
+ ./scripts/run-local.ps1
77
+ ```
78
+
79
+ ## Build / run
80
+
81
+ ```bash
82
+ npm run build
83
+ npm start
84
+ ```
85
+
86
+ Check version:
87
+
88
+ ```bash
89
+ npx @zegazone_mcp/mcp --version
90
+ ```
91
+
92
+ ## Smoke tests
93
+
94
+ ```bash
95
+ npm run smoke
96
+ ```
97
+
98
+ Smoke tests call the backing API directly and validate read + destructive dry-run paths. Use `ZEGA_ACCESS_TOKEN` or a credential file whose `access_token` is still valid (fresh pairing).
99
+
100
+ ## Hosted deployment (container)
101
+
102
+ ```bash
103
+ docker build -t zegazone-mcp .
104
+ docker run --rm \
105
+ -e ZEGA_API_BASE=https://api.zegaphone.com \
106
+ -e ZEGA_ACCESS_TOKEN=<token> \
107
+ -e ZEGA_DEFAULT_DRY_RUN=true \
108
+ zegazone-mcp
109
+ ```
110
+
111
+ Mount a credential file instead of `ZEGA_ACCESS_TOKEN` if you use pairing on the host:
112
+
113
+ ```bash
114
+ docker run --rm \
115
+ -v "$HOME/.zegazone-mcp:/root/.zegazone-mcp:ro" \
116
+ -e ZEGA_API_BASE=https://api.zegaphone.com \
117
+ -e ZEGA_DEFAULT_DRY_RUN=true \
118
+ zegazone-mcp
119
+ ```
120
+
121
+ ## npm
122
+
123
+ Published as [`@zegazone_mcp/mcp`](https://www.npmjs.com/package/@zegazone_mcp/mcp).
package/dist/api.js ADDED
@@ -0,0 +1,159 @@
1
+ import { STATUS_CODES } from "node:http";
2
+ import { DESTRUCTIVE_OPS, MUTATING_OPS } from "./ops.js";
3
+ function jsonReplacerForApiErrors(_key, value) {
4
+ if (value instanceof Error) {
5
+ return { name: value.name, message: value.message };
6
+ }
7
+ return value;
8
+ }
9
+ /** API / gateway `error` fields may be strings or structured objects — never rely on String(obj). */
10
+ function coerceApiErrorField(value) {
11
+ if (typeof value === "string")
12
+ return value;
13
+ if (typeof value === "number" || typeof value === "boolean")
14
+ return String(value);
15
+ if (value == null)
16
+ return "api_error";
17
+ try {
18
+ return JSON.stringify(value);
19
+ }
20
+ catch {
21
+ return "api_error";
22
+ }
23
+ }
24
+ /**
25
+ * Thrown on non-2xx API responses so MCP hosts see a real `Error#message` (plain objects become "[object Object]").
26
+ */
27
+ export class ThirdPartyRequestError extends Error {
28
+ payload;
29
+ constructor(payload) {
30
+ super(ThirdPartyRequestError.formatMessage(payload));
31
+ this.name = "ThirdPartyRequestError";
32
+ this.payload = payload;
33
+ }
34
+ static formatMessage(p) {
35
+ const phrase = STATUS_CODES[p.status] ?? "";
36
+ const headline = phrase ? `HTTP ${p.status} ${phrase}` : `HTTP ${p.status}`;
37
+ const summary = {
38
+ status: p.status,
39
+ error: p.error,
40
+ };
41
+ if (p.detail !== undefined)
42
+ summary.detail = p.detail;
43
+ if (p.field !== undefined)
44
+ summary.field = p.field;
45
+ if (p.need !== undefined)
46
+ summary.need = p.need;
47
+ if (p.raw !== undefined)
48
+ summary.raw = p.raw;
49
+ let body;
50
+ try {
51
+ body = JSON.stringify(summary, jsonReplacerForApiErrors, 2);
52
+ }
53
+ catch {
54
+ body = JSON.stringify({ status: p.status, error: p.error }, jsonReplacerForApiErrors, 2);
55
+ }
56
+ return `${headline}\n\n${body}`;
57
+ }
58
+ }
59
+ export function ensureDestructiveSafety(op, payload, defaultDryRun) {
60
+ if (!DESTRUCTIVE_OPS.has(op))
61
+ return payload;
62
+ const hasConfirm = payload.confirm === true;
63
+ const hasDryRun = payload.dry_run === true;
64
+ if (hasConfirm || hasDryRun)
65
+ return payload;
66
+ return {
67
+ ...payload,
68
+ dry_run: defaultDryRun,
69
+ };
70
+ }
71
+ export function ensureIdempotency(op, payload) {
72
+ if (!MUTATING_OPS.has(op))
73
+ return payload;
74
+ if (typeof payload.idempotency_key === "string" && payload.idempotency_key.trim()) {
75
+ return payload;
76
+ }
77
+ return {
78
+ ...payload,
79
+ idempotency_key: `mcp-${op}-${Date.now()}`,
80
+ };
81
+ }
82
+ async function callThirdPartyOnce(config, accessToken, op, args) {
83
+ const controller = new AbortController();
84
+ const timer = setTimeout(() => controller.abort(), config.timeoutMs);
85
+ try {
86
+ const body = JSON.stringify({
87
+ op,
88
+ ...args,
89
+ });
90
+ const res = await fetch(`${config.apiBase}/functions/v1/thirdparty-v1`, {
91
+ method: "POST",
92
+ headers: {
93
+ "Content-Type": "application/json",
94
+ Authorization: `Bearer ${accessToken}`,
95
+ },
96
+ body,
97
+ signal: controller.signal,
98
+ });
99
+ const parsed = (await res.json().catch(() => ({})));
100
+ if (!res.ok) {
101
+ const detail = typeof parsed.detail === "string"
102
+ ? parsed.detail
103
+ : typeof parsed.message === "string"
104
+ ? parsed.message
105
+ : undefined;
106
+ const errorObj = {
107
+ error: coerceApiErrorField(parsed.error ?? "api_error"),
108
+ detail,
109
+ field: typeof parsed.field === "string" ? parsed.field : undefined,
110
+ need: parsed.need ?? undefined,
111
+ status: res.status,
112
+ raw: parsed,
113
+ };
114
+ return { ok: false, error: errorObj };
115
+ }
116
+ return { ok: true, data: parsed };
117
+ }
118
+ catch (e) {
119
+ const message = e instanceof Error ? e.message : String(e);
120
+ throw new ThirdPartyRequestError({
121
+ error: "transport_error",
122
+ detail: message,
123
+ status: 502,
124
+ raw: e,
125
+ });
126
+ }
127
+ finally {
128
+ clearTimeout(timer);
129
+ }
130
+ }
131
+ export async function callThirdParty(config, op, args) {
132
+ let token = await config.getAccessToken();
133
+ let attempt = await callThirdPartyOnce(config, token, op, args);
134
+ if (!attempt.ok && attempt.error.status === 401) {
135
+ // The server rejected our token. The cached access_token may be structurally unexpired
136
+ // but signature-invalid (e.g. server-side JWT_SECRET rotated). Bypass every cache and
137
+ // run the OAuth refresh grant unconditionally.
138
+ let refreshed = null;
139
+ try {
140
+ refreshed = await config.forceRefreshAccessToken();
141
+ }
142
+ catch (e) {
143
+ // If we cannot refresh (legacy ZEGA_ACCESS_TOKEN mode, or refresh_token revoked),
144
+ // surface the original 401 with the refresh failure attached as context.
145
+ const refreshMsg = e instanceof Error ? e.message : String(e);
146
+ throw new ThirdPartyRequestError({
147
+ ...attempt.error,
148
+ detail: attempt.error.detail
149
+ ? `${attempt.error.detail} (token refresh also failed: ${refreshMsg})`
150
+ : `token refresh failed: ${refreshMsg}`,
151
+ });
152
+ }
153
+ attempt = await callThirdPartyOnce(config, refreshed, op, args);
154
+ }
155
+ if (!attempt.ok) {
156
+ throw new ThirdPartyRequestError(attempt.error);
157
+ }
158
+ return attempt.data;
159
+ }
package/dist/config.js ADDED
@@ -0,0 +1,47 @@
1
+ import fs from "node:fs";
2
+ import { createAccessTokenHandle } from "./token-provider.js";
3
+ import { defaultCredentialsPath } from "./token-store.js";
4
+ function readBool(input, fallback) {
5
+ if (!input)
6
+ return fallback;
7
+ const v = input.trim().toLowerCase();
8
+ return v === "1" || v === "true" || v === "yes";
9
+ }
10
+ function hasRefreshInFileSync(credentialsPath) {
11
+ try {
12
+ const raw = fs.readFileSync(credentialsPath, "utf8");
13
+ const parsed = JSON.parse(raw);
14
+ return typeof parsed.refresh_token === "string" && parsed.refresh_token.length > 0;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ export async function loadConfig() {
21
+ const apiBase = (process.env.ZEGA_API_BASE ?? "").trim().replace(/\/+$/, "");
22
+ if (!apiBase) {
23
+ throw new Error("Missing ZEGA_API_BASE");
24
+ }
25
+ const credentialsPath = defaultCredentialsPath();
26
+ const legacyAccessToken = (process.env.ZEGA_ACCESS_TOKEN ?? "").trim() || null;
27
+ const timeoutMsRaw = Number(process.env.ZEGA_TIMEOUT_MS ?? "30000");
28
+ const timeoutMs = Number.isFinite(timeoutMsRaw) && timeoutMsRaw > 0 ? timeoutMsRaw : 30000;
29
+ const fileHasRefresh = hasRefreshInFileSync(credentialsPath);
30
+ if (!fileHasRefresh && !legacyAccessToken) {
31
+ throw new Error(`Missing credentials. Run: npm run oauth-pair (saves ${credentialsPath}) or set ZEGA_ACCESS_TOKEN.`);
32
+ }
33
+ const handle = createAccessTokenHandle({
34
+ apiBase,
35
+ credentialsPath,
36
+ legacyAccessToken,
37
+ timeoutMs,
38
+ });
39
+ return {
40
+ apiBase,
41
+ defaultDryRun: readBool(process.env.ZEGA_DEFAULT_DRY_RUN, true),
42
+ timeoutMs,
43
+ getAccessToken: handle.getAccessToken,
44
+ forceRefreshAccessToken: handle.forceRefreshAccessToken,
45
+ invalidateAccessTokenCache: handle.invalidateMemory,
46
+ };
47
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { spawn } from "node:child_process";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
+ import { loadConfig } from "./config.js";
8
+ import { createServer } from "./server.js";
9
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
10
+ const packageRoot = path.resolve(moduleDir, "..");
11
+ function readVersion() {
12
+ const pkg = JSON.parse(readFileSync(path.join(packageRoot, "package.json"), "utf8"));
13
+ return pkg.version ?? "0.0.0";
14
+ }
15
+ async function runPair() {
16
+ const script = path.join(packageRoot, "scripts", "oauth-pair.mjs");
17
+ return new Promise((resolve) => {
18
+ const child = spawn(process.execPath, [script], {
19
+ stdio: "inherit",
20
+ env: process.env,
21
+ });
22
+ child.on("close", (code) => resolve(code ?? 1));
23
+ child.on("error", () => resolve(1));
24
+ });
25
+ }
26
+ async function main() {
27
+ const args = process.argv.slice(2);
28
+ if (args.includes("--version")) {
29
+ console.log(readVersion());
30
+ process.exit(0);
31
+ }
32
+ if (args.includes("--pair")) {
33
+ process.exit(await runPair());
34
+ }
35
+ const config = await loadConfig();
36
+ const server = createServer(config);
37
+ const transport = new StdioServerTransport();
38
+ await server.connect(transport);
39
+ }
40
+ main().catch((err) => {
41
+ const message = err instanceof Error ? err.stack ?? err.message : String(err);
42
+ console.error(`[zegazone-mcp] startup failed: ${message}`);
43
+ process.exit(1);
44
+ });
@@ -0,0 +1,67 @@
1
+ export async function exchangeAuthorizationCode(params) {
2
+ const body = JSON.stringify({
3
+ grant_type: "authorization_code",
4
+ client_id: params.clientId,
5
+ code: params.code,
6
+ redirect_uri: params.redirectUri,
7
+ code_verifier: params.codeVerifier,
8
+ });
9
+ return postToken(params.apiBase, body, params.timeoutMs);
10
+ }
11
+ export async function refreshWithStoredRefresh(params) {
12
+ const body = JSON.stringify({
13
+ grant_type: "refresh_token",
14
+ client_id: params.clientId,
15
+ refresh_token: params.refreshToken,
16
+ });
17
+ return postToken(params.apiBase, body, params.timeoutMs);
18
+ }
19
+ function toStoredCredentials(clientId, tokens) {
20
+ const access_expires_at_ms = Date.now() + Math.max(0, tokens.expires_in) * 1000;
21
+ return {
22
+ version: 1,
23
+ client_id: clientId,
24
+ refresh_token: tokens.refresh_token,
25
+ access_token: tokens.access_token,
26
+ access_expires_at_ms,
27
+ };
28
+ }
29
+ export async function refreshAndPersist(params) {
30
+ const tokens = await refreshWithStoredRefresh({
31
+ apiBase: params.apiBase,
32
+ clientId: params.previous.client_id,
33
+ refreshToken: params.previous.refresh_token,
34
+ timeoutMs: params.timeoutMs,
35
+ });
36
+ const next = toStoredCredentials(params.previous.client_id, tokens);
37
+ await params.writeStoredCredentials(params.credentialsPath, next);
38
+ return next;
39
+ }
40
+ async function postToken(apiBase, body, timeoutMs) {
41
+ const controller = new AbortController();
42
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
43
+ try {
44
+ const res = await fetch(`${apiBase}/functions/v1/thirdparty-oauth-token`, {
45
+ method: "POST",
46
+ headers: { "Content-Type": "application/json" },
47
+ body,
48
+ signal: controller.signal,
49
+ });
50
+ const parsed = (await res.json().catch(() => ({})));
51
+ if (!res.ok) {
52
+ const err = String(parsed.error ?? "token_error");
53
+ const detail = typeof parsed.detail === "string" ? parsed.detail : JSON.stringify(parsed);
54
+ throw new Error(`OAuth token endpoint failed (${res.status}): ${err} — ${detail}`);
55
+ }
56
+ const access_token = typeof parsed.access_token === "string" ? parsed.access_token : "";
57
+ const refresh_token = typeof parsed.refresh_token === "string" ? parsed.refresh_token : "";
58
+ const expires_in = typeof parsed.expires_in === "number" ? parsed.expires_in : 3600;
59
+ if (!access_token || !refresh_token) {
60
+ throw new Error("OAuth token response missing access_token or refresh_token");
61
+ }
62
+ return { access_token, refresh_token, expires_in };
63
+ }
64
+ finally {
65
+ clearTimeout(timer);
66
+ }
67
+ }
package/dist/ops.js ADDED
@@ -0,0 +1,100 @@
1
+ export const OP_TOOL_MAP = {
2
+ ping: "ping",
3
+ schema_get: "schema.get",
4
+ operations_list: "operations.list",
5
+ operation_describe: "operation.describe",
6
+ ui_state_get: "ui.state.get",
7
+ ui_state_set: "ui.state.set",
8
+ collections_list: "collections.list",
9
+ collections_batch_get: "collections.batch.get",
10
+ collections_export: "collections.export",
11
+ collections_create: "collections.create",
12
+ collections_update: "collections.update",
13
+ collections_delete: "collections.delete",
14
+ collections_restore: "collections.restore",
15
+ collections_reorder: "collections.reorder",
16
+ collections_archive: "collections.archive",
17
+ collections_unarchive: "collections.unarchive",
18
+ collections_like: "collections.like",
19
+ collections_unlike: "collections.unlike",
20
+ collections_liked_list: "collections.liked.list",
21
+ collections_search: "collections.search",
22
+ collections_browse: "collections.browse",
23
+ collections_stats_get: "collections.stats.get",
24
+ collections_share_url: "collections.share_url",
25
+ collections_publish: "collections.publish",
26
+ collections_unpublish: "collections.unpublish",
27
+ aliases_list: "aliases.list",
28
+ aliases_follow: "aliases.follow",
29
+ aliases_unfollow: "aliases.unfollow",
30
+ aliases_following_list: "aliases.following.list",
31
+ profile_get: "profile.get",
32
+ profile_update: "profile.update",
33
+ collaborators_list: "collaborators.list",
34
+ collaborators_invite: "collaborators.invite",
35
+ collaborators_revoke: "collaborators.revoke",
36
+ collaborators_invites_list: "collaborators.invites.list",
37
+ collaborators_invites_received: "collaborators.invites.received",
38
+ collaborators_invite_accept: "collaborators.invite.accept",
39
+ collaborators_invite_decline: "collaborators.invite.decline",
40
+ media_list: "media.list",
41
+ media_search: "media.search",
42
+ media_batch_list: "media.batch.list",
43
+ media_download: "media.download",
44
+ media_create: "media.create",
45
+ media_update: "media.update",
46
+ media_describe: "media.describe",
47
+ media_replace: "media.replace",
48
+ media_delete: "media.delete",
49
+ media_restore: "media.restore",
50
+ media_move: "media.move",
51
+ media_copy: "media.copy",
52
+ media_reorder: "media.reorder",
53
+ media_archive: "media.archive",
54
+ media_unarchive: "media.unarchive",
55
+ media_text_note_add: "media.text_note.add",
56
+ media_text_note_get: "media.text_note.get",
57
+ media_text_note_update: "media.text_note.update",
58
+ media_text_note_delete: "media.text_note.delete",
59
+ };
60
+ export const DESTRUCTIVE_OPS = new Set([
61
+ "collections.delete",
62
+ "collections.restore",
63
+ "media.delete",
64
+ "media.restore",
65
+ "media.text_note.delete",
66
+ ]);
67
+ export const MUTATING_OPS = new Set([
68
+ "ui.state.set",
69
+ "collections.create",
70
+ "collections.update",
71
+ "collections.delete",
72
+ "collections.restore",
73
+ "collections.reorder",
74
+ "collections.archive",
75
+ "collections.unarchive",
76
+ "collections.like",
77
+ "collections.unlike",
78
+ "collections.publish",
79
+ "collections.unpublish",
80
+ "aliases.follow",
81
+ "aliases.unfollow",
82
+ "profile.update",
83
+ "collaborators.invite",
84
+ "collaborators.revoke",
85
+ "collaborators.invite.accept",
86
+ "collaborators.invite.decline",
87
+ "media.create",
88
+ "media.update",
89
+ "media.replace",
90
+ "media.delete",
91
+ "media.restore",
92
+ "media.move",
93
+ "media.copy",
94
+ "media.reorder",
95
+ "media.archive",
96
+ "media.unarchive",
97
+ "media.text_note.add",
98
+ "media.text_note.update",
99
+ "media.text_note.delete",
100
+ ]);