@1llum1n4t1/cws-mcp 1.4.2 → 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.ko.md CHANGED
@@ -13,7 +13,6 @@ Chrome Web Store 확장 프로그램 관리를 위한 MCP 서버. Claude Code
13
13
  - **"크롬 확장 프로그램 새 버전 업로드해줘"** — ZIP을 빌드하고 `upload` 도구로 초안 업데이트
14
14
  - **"확장 프로그램 Chrome Web Store에 퍼블리시해줘"** — `publish`로 리뷰 제출 및 배포
15
15
  - **"확장 프로그램 리뷰 상태 확인해줘"** — `status`로 리뷰 상태, 버전, 배포 비율 확인
16
- - **"확장 프로그램 메타데이터(설명, 스크린샷) 업데이트해줘"** — `update-metadata-ui`로 스토어 리스팅 수정
17
16
  - **"제출 대기 중인 거 취소해줘"** — `cancel`로 리뷰 중인 제출 철회
18
17
  - **"확장 프로그램 단계적 배포 설정해줘"** — `publish`로 단계적 배포 후 `deploy-percentage`로 비율 증가
19
18
 
@@ -23,12 +22,12 @@ Chrome Web Store 확장 프로그램 관리를 위한 MCP 서버. Claude Code
23
22
  |---|---|
24
23
  | `upload` | ZIP 파일을 Chrome Web Store에 업로드 (기존 항목 초안 업데이트) |
25
24
  | `publish` | 단계적 배포, 퍼블리시 유형, 리뷰 건너뛰기 옵션으로 확장 프로그램 퍼블리시 |
25
+ | `submit` | 원샷: upload → publish → status를 한 번의 호출로 실행 (존재 여부 preflight 체크와 읽기 쉬운 에러 메시지 포함) |
26
26
  | `status` | 리뷰 상태, 배포 비율, 버전 등 현재 상태 확인 |
27
27
  | `cancel` | 제출 대기 중인 항목 취소 |
28
28
  | `deploy-percentage` | 단계적 배포 비율 설정 (0-100, 현재 목표보다 높아야 함) |
29
29
  | `get` | DRAFT/PUBLISHED 리스팅 메타데이터 조회 (v1.1 API, 2026년 10월 지원 종료) |
30
30
  | `update-metadata` | v1.1 API로 리스팅 메타데이터 업데이트 (2026년 10월 지원 종료) |
31
- | `update-metadata-ui` | 대시보드 UI 자동화(Playwright)로 리스팅 메타데이터 업데이트 |
32
31
 
33
32
  ## API 커버리지
34
33
 
@@ -42,7 +41,9 @@ Chrome Web Store 확장 프로그램 관리를 위한 MCP 서버. Claude Code
42
41
  | `publishers.items.cancelSubmission` | `cancel` |
43
42
  | `publishers.items.setPublishedDeployPercentage` | `deploy-percentage` |
44
43
 
45
- 추가로, 메타데이터 조작을 위한 v1.1 API 엔드포인트(`get`, `update-metadata`)가 제공되며, v1 지원 종료에 대비하여 대시보드 UI 자동화(`update-metadata-ui`)를 권장합니다.
44
+ 추가로, 메타데이터 조작을 위한 v1.1 API 엔드포인트(`get`, `update-metadata`)가 제공됩니다. v1.1 지원 종료 이후의 리스팅 변경은 Chrome Web Store 개발자 대시보드에서 수동으로 진행하세요 (v2 API에는 메타데이터 쓰기 엔드포인트가 없으며, 이 MCP는 브라우저 자동화를 사용하지 않습니다).
45
+
46
+ Chrome Web Store API는 새 항목(아이템) 생성을 지원하지 않습니다. 새 항목은 Chrome Web Store 개발자 대시보드에서 수동으로 만든 뒤, 발급된 Item ID를 `CWS_ITEM_ID`에 설정해 이 도구들로 관리하세요.
46
47
 
47
48
  ## 설정
48
49
 
@@ -135,7 +136,6 @@ Claude Code MCP 설정 (`~/.claude/settings.local.json`)에 추가합니다.
135
136
  | `CWS_REFRESH_TOKEN` | 인증 B | OAuth2 Refresh Token |
136
137
  | `CWS_PUBLISHER_ID` | 아니오 | 퍼블리셔 ID (기본값: `me`) |
137
138
  | `CWS_ITEM_ID` | 아니오 | 기본 확장 프로그램 Item ID |
138
- | `CWS_DASHBOARD_PROFILE_DIR` | 아니오 | `update-metadata-ui`용 브라우저 프로필 경로 (기본값: `~/.cws-mcp-profile`) |
139
139
 
140
140
  ## 사용 예시
141
141
 
@@ -181,22 +181,6 @@ cws-mcp update-metadata에서 metadata 객체 전달:
181
181
  }
182
182
  ```
183
183
 
184
- ### API 반영이 안 되는 경우(UI 자동화)
185
- ```
186
- cws-mcp update-metadata-ui 사용:
187
- - title
188
- - summary
189
- - description
190
- - category
191
- - homepageUrl
192
- - supportUrl
193
- ```
194
-
195
- 참고:
196
- - 이 도구는 Chrome Web Store 대시보드 UI를 자동 조작합니다.
197
- - 로그인 필요 시 `headless=false`로 1회 실행해 로그인하세요.
198
- - 브라우저 프로필 기본 경로: `~/.cws-mcp-profile` (`CWS_DASHBOARD_PROFILE_DIR`로 변경 가능)
199
-
200
184
  ### 단계적 배포
201
185
  ```
202
186
  1. cws-mcp publish
@@ -209,7 +193,7 @@ cws-mcp update-metadata-ui 사용:
209
193
 
210
194
  ## V1 API 지원 종료 안내
211
195
 
212
- `get`과 `update-metadata` 도구는 Chrome Web Store v1.1 API를 사용하며, **2026년 10월 15일 이후 지원이 종료**됩니다. v2 API에는 메타데이터 읽기/쓰기 엔드포인트가 없어 이 도구들이 브릿지 역할을 합니다. 장기적으로는 `update-metadata-ui` (Playwright 대시보드 자동화)를 대안으로 사용하세요.
196
+ `get`과 `update-metadata` 도구는 Chrome Web Store v1.1 API를 사용하며, **2026년 10월 15일 이후 지원이 종료**됩니다. v2 API에는 메타데이터 읽기/쓰기 엔드포인트가 없어 이 도구들이 브릿지 역할을 합니다. v1.1 지원 종료 이후의 리스팅 변경은 Chrome Web Store 개발자 대시보드에서 수동으로 진행하세요 (v2 API에는 메타데이터 쓰기 엔드포인트가 없으며, 이 MCP는 브라우저 자동화를 사용하지 않습니다).
213
197
 
214
198
  ## 라이선스
215
199
 
package/README.md CHANGED
@@ -15,7 +15,7 @@ Use this MCP when you need to:
15
15
  - **"Upload a new version of my Chrome extension"** — build your ZIP and use the `upload` tool to push it as a draft
16
16
  - **"Publish my extension to the Chrome Web Store"** — use `publish` to submit for review and go live
17
17
  - **"Check the review status of my extension"** — use `status` to see review state, version, and deploy percentage
18
- - **"Update my extension's metadata (description, screenshots)"** — use `update-metadata-ui` to change store listing details
18
+ - **"Upload and publish in one step"** — use `submit` to run upload publish → status as a single call
19
19
  - **"Cancel a pending submission"** — use `cancel` to withdraw a submission under review
20
20
  - **"Set up staged rollout for my extension"** — use `publish` with staged rollout, then `deploy-percentage` to ramp up
21
21
 
@@ -30,7 +30,9 @@ Use this MCP when you need to:
30
30
  | `deploy-percentage` | Set staged rollout percentage (0-100, must exceed current target) |
31
31
  | `get` | Read draft/published listing metadata (v1.1 API, deprecated Oct 2026) |
32
32
  | `update-metadata` | Update listing metadata via v1.1 API (deprecated Oct 2026) |
33
- | `update-metadata-ui` | Update listing metadata via dashboard UI automation (Playwright) |
33
+ | `submit` | One-shot: run upload publish status in a single call (with existence preflight and readable errors) |
34
+
35
+ > The Chrome Web Store API cannot create items. Create a new item manually in the Chrome Web Store Developer Dashboard, then use `upload` / `publish` (or `submit`) against its item ID.
34
36
 
35
37
  ## API Coverage
36
38
 
@@ -44,7 +46,7 @@ This MCP server covers **all Chrome Web Store API v2 endpoints**:
44
46
  | `publishers.items.cancelSubmission` | `cancel` |
45
47
  | `publishers.items.setPublishedDeployPercentage` | `deploy-percentage` |
46
48
 
47
- Additionally, v1.1 API endpoints are available for metadata operations (`get`, `update-metadata`), with dashboard UI automation (`update-metadata-ui`) as the recommended alternative since v1 is deprecated.
49
+ Additionally, v1.1 API endpoints are available for metadata operations (`get`, `update-metadata`). Since v1.1 is deprecated, listing changes after its removal must be made manually in the Chrome Web Store Developer Dashboard (the v2 API has no metadata write endpoint, and this MCP does not automate the browser).
48
50
 
49
51
  ## Setup
50
52
 
@@ -137,7 +139,6 @@ Provide **either** a service-account key (Auth A) **or** the OAuth2 refresh-toke
137
139
  | `CWS_REFRESH_TOKEN` | Auth B | OAuth2 Refresh Token |
138
140
  | `CWS_PUBLISHER_ID` | No | Publisher ID (default: `me`) |
139
141
  | `CWS_ITEM_ID` | No | Default extension item ID |
140
- | `CWS_DASHBOARD_PROFILE_DIR` | No | Browser profile path for `update-metadata-ui` (default: `~/.cws-mcp-profile`) |
141
142
 
142
143
  ## Usage Examples
143
144
 
@@ -182,22 +183,6 @@ Use cws-mcp update-metadata with metadata={
182
183
  }
183
184
  ```
184
185
 
185
- ### When API metadata updates don't reflect
186
- ```
187
- Use cws-mcp update-metadata-ui with:
188
- - title
189
- - summary
190
- - description
191
- - category
192
- - homepageUrl
193
- - supportUrl
194
- ```
195
-
196
- Notes:
197
- - This tool automates the Chrome Web Store dashboard UI.
198
- - First run with `headless=false` if login is required.
199
- - Browser profile path defaults to `~/.cws-mcp-profile` (override with `CWS_DASHBOARD_PROFILE_DIR`).
200
-
201
186
  ### Staged rollout
202
187
  ```
203
188
  1. Use cws-mcp publish
@@ -210,7 +195,7 @@ Note: `deploy-percentage` is only available for extensions with 10,000+ seven-da
210
195
 
211
196
  ## V1 API Deprecation
212
197
 
213
- The `get` and `update-metadata` tools use the Chrome Web Store v1.1 API, which is **deprecated and will be removed after October 15, 2026**. The v2 API does not provide metadata read/write endpoints, so these tools remain available as a bridge. Use `update-metadata-ui` (Playwright dashboard automation) as the long-term alternative.
198
+ The `get` and `update-metadata` tools use the Chrome Web Store v1.1 API, which is **deprecated and will be removed after October 15, 2026**. The v2 API does not provide metadata read/write endpoints, so these tools remain available as a bridge. After v1.1 is removed, make listing changes manually in the Chrome Web Store Developer Dashboard — the v2 API has no metadata write endpoint, and this MCP does not automate the browser.
214
199
 
215
200
  ## License
216
201
 
package/dist/index.js CHANGED
@@ -4,8 +4,6 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { z } from "zod";
5
5
  import { readFileSync } from "node:fs";
6
6
  import { createSign } from "node:crypto";
7
- import { chromium } from "playwright";
8
- import { homedir } from "node:os";
9
7
  import { resolve, join, dirname } from "node:path";
10
8
  import { fileURLToPath } from "node:url";
11
9
  // ── Version ──
@@ -27,9 +25,10 @@ const TOKEN_URL = "https://oauth2.googleapis.com/token";
27
25
  const SCOPE = "https://www.googleapis.com/auth/chromewebstore";
28
26
  /** Date after which Google removes the Chrome Web Store v1.1 API. */
29
27
  const V1_SUNSET = "2026-10-15";
30
- const DASHBOARD_PROFILE_DIR = process.env.CWS_DASHBOARD_PROFILE_DIR || resolve(homedir(), ".cws-mcp-profile");
31
28
  // ── Auth: OAuth2 refresh token & service account (JWT bearer) ──
32
29
  let cachedToken = null;
30
+ /** 取得中のトークン Promise(single-flight 用。並行取得の多重発火を防ぐ)。 */
31
+ let tokenInflight = null;
33
32
  /** Load a service account key from CWS_SERVICE_ACCOUNT_KEY (raw JSON or a file path). Returns null when not configured. */
34
33
  function loadServiceAccount() {
35
34
  const value = SERVICE_ACCOUNT_KEY.trim();
@@ -77,6 +76,7 @@ async function fetchTokenViaServiceAccount(sa) {
77
76
  method: "POST",
78
77
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
79
78
  body: body.toString(),
79
+ signal: AbortSignal.timeout(30_000),
80
80
  });
81
81
  if (!res.ok) {
82
82
  throw new Error(`Service account token request failed (${res.status}): ${await res.text()}`);
@@ -95,6 +95,7 @@ async function fetchTokenViaRefreshToken() {
95
95
  method: "POST",
96
96
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
97
97
  body: body.toString(),
98
+ signal: AbortSignal.timeout(30_000),
98
99
  });
99
100
  if (!res.ok) {
100
101
  throw new Error(`Token refresh failed (${res.status}): ${await res.text()}`);
@@ -109,23 +110,42 @@ async function getAccessToken() {
109
110
  if (cachedToken && Date.now() < cachedToken.expires_at - 60_000) {
110
111
  return cachedToken.access_token;
111
112
  }
112
- const sa = loadServiceAccount();
113
- let data;
114
- if (sa) {
115
- data = await fetchTokenViaServiceAccount(sa);
116
- }
117
- else if (CLIENT_ID && CLIENT_SECRET && REFRESH_TOKEN) {
118
- data = await fetchTokenViaRefreshToken();
113
+ // 並行リクエストでトークンを多重取得しないよう、取得中は同じ Promise を共有する。
114
+ if (tokenInflight)
115
+ return tokenInflight;
116
+ tokenInflight = (async () => {
117
+ const sa = loadServiceAccount();
118
+ let data;
119
+ if (sa) {
120
+ data = await fetchTokenViaServiceAccount(sa);
121
+ }
122
+ else if (CLIENT_ID && CLIENT_SECRET && REFRESH_TOKEN) {
123
+ data = await fetchTokenViaRefreshToken();
124
+ }
125
+ else {
126
+ throw new Error("Missing credentials. Set CWS_SERVICE_ACCOUNT_KEY (service account), " +
127
+ "or CWS_CLIENT_ID + CWS_CLIENT_SECRET + CWS_REFRESH_TOKEN (OAuth refresh token).");
128
+ }
129
+ // トークンエンドポイントの想定外レスポンス(access_token 欠落 / expires_in 非数)を
130
+ // そのままキャッシュすると Bearer undefined や expires_at=NaN を生むので明示的に弾く。
131
+ if (typeof data.access_token !== "string" ||
132
+ !data.access_token ||
133
+ typeof data.expires_in !== "number" ||
134
+ !Number.isFinite(data.expires_in)) {
135
+ throw new Error("Token endpoint returned an unexpected response (missing access_token or expires_in).");
136
+ }
137
+ cachedToken = {
138
+ access_token: data.access_token,
139
+ expires_at: Date.now() + data.expires_in * 1000,
140
+ };
141
+ return cachedToken.access_token;
142
+ })();
143
+ try {
144
+ return await tokenInflight;
119
145
  }
120
- else {
121
- throw new Error("Missing credentials. Set CWS_SERVICE_ACCOUNT_KEY (service account), " +
122
- "or CWS_CLIENT_ID + CWS_CLIENT_SECRET + CWS_REFRESH_TOKEN (OAuth refresh token).");
146
+ finally {
147
+ tokenInflight = null;
123
148
  }
124
- cachedToken = {
125
- access_token: data.access_token,
126
- expires_at: Date.now() + data.expires_in * 1000,
127
- };
128
- return cachedToken.access_token;
129
149
  }
130
150
  // ── Helpers ──
131
151
  function errMsg(e) {
@@ -136,21 +156,99 @@ function resolveItemId(itemId) {
136
156
  if (!id) {
137
157
  throw new Error("No item ID provided. Pass itemId parameter or set CWS_ITEM_ID env var.");
138
158
  }
139
- return id;
159
+ // URL パスセグメントとして安全化(itemId 経由で verb/クエリをすり替えられないように)。
160
+ return encodeURIComponent(id);
140
161
  }
141
162
  function resolvePublisherId(publisherId) {
142
- return publisherId || PUBLISHER_ID;
163
+ return encodeURIComponent(publisherId || PUBLISHER_ID);
164
+ }
165
+ /** v2 API のアイテム操作 URL を組み立てる(pub/id は呼び出し側で安全化済みの前提)。 */
166
+ function itemUrl(pub, id, action) {
167
+ return `${API_BASE}/v2/publishers/${pub}/items/${id}:${action}`;
143
168
  }
144
- async function apiCall(url, options) {
169
+ /** publish / submit 共通の publish リクエストボディを組み立てる。 */
170
+ function buildPublishBody(opts) {
171
+ const body = {};
172
+ if (opts.publishType)
173
+ body.publishType = opts.publishType;
174
+ if (opts.deployPercentage !== undefined)
175
+ body.deployInfos = [{ deployPercentage: opts.deployPercentage }];
176
+ if (opts.skipReview !== undefined)
177
+ body.skipReview = opts.skipReview;
178
+ if (opts.blockOnWarnings !== undefined)
179
+ body.blockOnWarnings = opts.blockOnWarnings;
180
+ return body;
181
+ }
182
+ async function apiCall(url, options, timeoutMs = 60_000) {
145
183
  const token = await getAccessToken();
146
184
  const headers = {
147
185
  Authorization: `Bearer ${token}`,
148
186
  ...(options.headers || {}),
149
187
  };
150
- const res = await fetch(url, { ...options, headers });
188
+ // タイムアウトが無いとネットワークストール時に stdio リクエストが無言ハングする。
189
+ const res = await fetch(url, { ...options, headers, signal: AbortSignal.timeout(timeoutMs) });
151
190
  const body = await res.text();
191
+ // 認証/認可エラー時はキャッシュした古いトークンを破棄し、次回の再取得で自己回復させる。
192
+ if (res.status === 401 || res.status === 403)
193
+ cachedToken = null;
152
194
  return { ok: res.ok, status: res.status, body };
153
195
  }
196
+ /**
197
+ * 既知の Chrome Web Store エラーを「次に何をすればよいか」が分かる助言に翻訳する。
198
+ * 該当しなければ null を返す(呼び出し側は生エラーのみを見せる)。
199
+ */
200
+ function interpretCwsError(status, body) {
201
+ const b = body.toLowerCase();
202
+ // itemId が空・不正・未作成(今回の ReplaceTranslator 404 ブロッカーの正体)。
203
+ if (b.includes("could not find handler")) {
204
+ return ("Hint: the item ID looks empty or malformed (the request URL had no valid item ID). " +
205
+ "For a brand-new extension, create the item first in the Developer Dashboard (the CWS API cannot create items), " +
206
+ "then set CWS_ITEM_ID / pass itemId.");
207
+ }
208
+ // publisher が見つからない(generic 404 より先に判定)。
209
+ if (b.includes("publisher") && (b.includes("not found") || b.includes("no publisher"))) {
210
+ return "Hint: publisher not found. Check CWS_PUBLISHER_ID (or pass publisherId). 'me' targets the authenticated publisher.";
211
+ }
212
+ // 認証・権限。
213
+ if (status === 401 ||
214
+ status === 403 ||
215
+ b.includes("invalid_grant") ||
216
+ b.includes("unauthorized") ||
217
+ b.includes("invalid credentials") ||
218
+ b.includes("insufficient permission")) {
219
+ return ("Hint: authentication/authorization failed. Check CWS_SERVICE_ACCOUNT_KEY (service account) " +
220
+ "or CWS_CLIENT_ID / CWS_CLIENT_SECRET / CWS_REFRESH_TOKEN, confirm the account owns this item/publisher, " +
221
+ "and that the OAuth scope includes chromewebstore.");
222
+ }
223
+ // 審査中で更新不可。
224
+ if (b.includes("in review") ||
225
+ b.includes("pending review") ||
226
+ b.includes("being reviewed") ||
227
+ b.includes("not updatable") ||
228
+ b.includes("item_not_updatable") ||
229
+ b.includes("review in progress")) {
230
+ return ("Hint: the item is currently in review and cannot be updated. " +
231
+ "Wait for the review to finish, or cancel the pending submission with the 'cancel' tool, then re-upload.");
232
+ }
233
+ // version 重複。
234
+ if (b.includes("version already exists") ||
235
+ b.includes("already been uploaded") ||
236
+ b.includes("duplicate version") ||
237
+ (b.includes("version") && b.includes("conflict"))) {
238
+ return ("Hint: this version already exists on the store. " +
239
+ "Bump the 'version' field in the extension's manifest.json, rebuild the ZIP, and retry.");
240
+ }
241
+ // レート制限 / quota。
242
+ if (status === 429 || b.includes("quota") || b.includes("rate limit") || b.includes("too many requests")) {
243
+ return "Hint: rate limit / quota exceeded. Wait a bit and retry.";
244
+ }
245
+ // 上記に当てはまらない 404 / not found は item 不在として案内。
246
+ if (status === 404 || b.includes("not found")) {
247
+ return ("Hint: the item ID does not exist or you lack access to it. " +
248
+ "Double-check the 32-char item ID (CWS_ITEM_ID / itemId); for a new extension create it first in the Developer Dashboard.");
249
+ }
250
+ return null;
251
+ }
154
252
  /** Format an API response with structured error info when applicable. */
155
253
  function formatResponse(result) {
156
254
  if (result.ok) {
@@ -167,8 +265,10 @@ function formatResponse(result) {
167
265
  catch {
168
266
  // Keep raw body
169
267
  }
268
+ const hint = interpretCwsError(result.status, result.body);
269
+ const text = `API Error (${result.status}): ${errorDetail}` + (hint ? `\n\n${hint}` : "");
170
270
  return {
171
- content: [{ type: "text", text: `API Error (${result.status}): ${errorDetail}` }],
271
+ content: [{ type: "text", text }],
172
272
  isError: true,
173
273
  };
174
274
  }
@@ -177,90 +277,116 @@ function appendNote(result, note) {
177
277
  return { ...result, content: [...result.content, { type: "text", text: note }] };
178
278
  }
179
279
  const V1_NOTE = `⚠️ This tool uses the Chrome Web Store v1.1 API, which Google will remove after ${V1_SUNSET}. ` +
180
- `The v2 API has no metadata read/write endpoint — for store-listing changes, prefer the 'update-metadata-ui' tool.`;
280
+ `The v2 API has no metadata read/write endpoint — after the sunset, change the store listing in the Developer Dashboard.`;
281
+ /**
282
+ * v1.1 API の sunset 後は API を叩く前に明示エラーを返す(404 の「item 不在」誤誘導を防ぐ)。
283
+ * sunset 前は null を返し通常フローへ進む。
284
+ */
285
+ function v1SunsetGuard() {
286
+ if (Date.now() > Date.parse(`${V1_SUNSET}T00:00:00Z`)) {
287
+ return {
288
+ content: [
289
+ {
290
+ type: "text",
291
+ text: `The Chrome Web Store v1.1 API was removed after ${V1_SUNSET}. ` +
292
+ `Change the store listing in the Developer Dashboard (the v2 API has no metadata read/write endpoint).`,
293
+ },
294
+ ],
295
+ isError: true,
296
+ };
297
+ }
298
+ return null;
299
+ }
181
300
  function toolError(e) {
182
301
  return { content: [{ type: "text", text: `Error: ${errMsg(e)}` }], isError: true };
183
302
  }
184
- function escapeRegExp(value) {
185
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
186
- }
187
- async function fillTextFieldByLabel(page, labels, value) {
188
- const parts = labels.map(escapeRegExp).join("|");
189
- const regex = new RegExp(parts, "i");
190
- const candidates = [
191
- page.getByLabel(regex).first(),
192
- page.getByRole("textbox", { name: regex }).first(),
193
- page.getByPlaceholder(regex).first(),
194
- ];
195
- for (const locator of candidates) {
196
- if ((await locator.count()) > 0) {
197
- await locator.fill(value);
198
- return;
199
- }
303
+ /**
304
+ * v2 fetchStatus の JSON から人間向けの短いサマリを作る。
305
+ * V2 のフィールド名に幅があるため防御的に拾い、拒否理由・違反・警告も再帰収集する。
306
+ * 解釈不能なら null。
307
+ */
308
+ function summarizeStatus(raw) {
309
+ let data;
310
+ try {
311
+ data = JSON.parse(raw);
200
312
  }
201
- const labelNode = page.getByText(regex).first();
202
- if ((await labelNode.count()) > 0) {
203
- const container = labelNode.locator("xpath=ancestor::*[self::div or self::section][1]");
204
- const field = container.locator("textarea, input[type='text'], input:not([type])").first();
205
- if ((await field.count()) > 0) {
206
- await field.fill(value);
207
- return;
208
- }
313
+ catch {
314
+ return null;
209
315
  }
210
- throw new Error(`Unable to locate field by labels: ${labels.join(", ")}`);
211
- }
212
- async function uploadFileBySectionLabel(page, labels, filePath) {
213
- const resolvedPath = resolve(filePath);
214
- const parts = labels.map(escapeRegExp).join("|");
215
- const regex = new RegExp(parts, "i");
216
- const labelNode = page.getByText(regex).first();
217
- if ((await labelNode.count()) > 0) {
218
- const container = labelNode.locator("xpath=ancestor::*[self::div or self::section][1]");
219
- const fileInput = container.locator("input[type='file']").first();
220
- if ((await fileInput.count()) > 0) {
221
- await fileInput.setInputFiles(resolvedPath);
222
- await page.waitForTimeout(1200);
223
- return;
316
+ if (!data || typeof data !== "object")
317
+ return null;
318
+ const obj = data;
319
+ const pick = (...keys) => {
320
+ for (const k of keys) {
321
+ const v = obj[k];
322
+ if (typeof v === "string" && v)
323
+ return v;
324
+ if (typeof v === "number")
325
+ return String(v);
224
326
  }
225
- }
226
- const anyFileInput = page.locator("input[type='file']").first();
227
- if ((await anyFileInput.count()) > 0) {
228
- await anyFileInput.setInputFiles(resolvedPath);
229
- await page.waitForTimeout(1200);
230
- return;
231
- }
232
- throw new Error(`Unable to locate file input for labels: ${labels.join(", ")}`);
233
- }
234
- async function clickSaveButton(page) {
235
- const roleCandidates = [
236
- page.getByRole("button", { name: /save|저장|임시저장|save draft/i }).first(),
237
- page.getByRole("button", { name: /submit for review|검토/i }).first(),
238
- ];
239
- for (const saveBtn of roleCandidates) {
240
- if ((await saveBtn.count()) > 0) {
241
- await saveBtn.click();
242
- await page.waitForTimeout(2000);
327
+ return undefined;
328
+ };
329
+ const lines = [];
330
+ const state = pick("status", "state", "reviewStatus", "itemStatus");
331
+ if (state)
332
+ lines.push(`State: ${state}`);
333
+ const version = pick("crxVersion", "version", "publishedVersion");
334
+ if (version)
335
+ lines.push(`Version: ${version}`);
336
+ const deploy = pick("deployPercentage", "publishedDeployPercentage");
337
+ if (deploy !== undefined)
338
+ lines.push(`Deploy: ${deploy}%`);
339
+ // 拒否理由・違反・警告・詳細メッセージを再帰的に集める。
340
+ const reasons = [];
341
+ const collect = (node, depth) => {
342
+ if (depth > 6 || node == null)
243
343
  return;
244
- }
245
- }
246
- const textCandidates = [
247
- page.locator("button:has-text('저장')").first(),
248
- page.locator("button:has-text('임시저장')").first(),
249
- page.locator("button:has-text('Save')").first(),
250
- ];
251
- for (const saveBtn of textCandidates) {
252
- if ((await saveBtn.count()) > 0) {
253
- await saveBtn.click();
254
- await page.waitForTimeout(2000);
344
+ if (Array.isArray(node)) {
345
+ for (const x of node)
346
+ collect(x, depth + 1);
255
347
  return;
256
348
  }
349
+ if (typeof node === "object") {
350
+ for (const [k, v] of Object.entries(node)) {
351
+ const lk = k.toLowerCase();
352
+ const looksLikeIssue = lk.includes("reason") ||
353
+ lk.includes("violation") ||
354
+ lk.includes("rejection") ||
355
+ lk.includes("warning") ||
356
+ lk.includes("detail") ||
357
+ lk.includes("message");
358
+ if (looksLikeIssue && typeof v === "string" && v.trim()) {
359
+ reasons.push(`${k}: ${v.trim()}`);
360
+ }
361
+ else if (looksLikeIssue && Array.isArray(v)) {
362
+ for (const item of v) {
363
+ if (typeof item === "string" && item.trim())
364
+ reasons.push(`${k}: ${item.trim()}`);
365
+ else
366
+ collect(item, depth + 1);
367
+ }
368
+ }
369
+ else {
370
+ collect(v, depth + 1);
371
+ }
372
+ }
373
+ }
374
+ };
375
+ collect(obj, 0);
376
+ const uniqReasons = [...new Set(reasons)];
377
+ if (uniqReasons.length > 0) {
378
+ lines.push("Issues:");
379
+ for (const r of uniqReasons.slice(0, 20))
380
+ lines.push(` - ${r}`);
257
381
  }
258
- if ((await page.getByText(/항목이 저장되었습니다|saved/i).count()) > 0) {
259
- return;
260
- }
261
- if ((await page.getByText(/변경사항이 저장되지 않았|unsaved/i).count()) === 0) {
262
- throw new Error("Save button not found on dashboard page.");
382
+ else {
383
+ // 拒否理由を自動抽出できなかった場合も「Issues なし=承認」と誤読されないよう明示する。
384
+ // Google のレスポンス形状変更でキー名マッチが外れても、生レスポンスの確認へ誘導する。
385
+ lines.push("Issues: none auto-extracted verify the raw status response for any rejection details.");
263
386
  }
387
+ if (lines.length === 0)
388
+ return null;
389
+ return ["── Summary ──", ...lines].join("\n");
264
390
  }
265
391
  // ── Shared tool schemas (single source of truth for main server + sandbox) ──
266
392
  const itemIdSchema = z
@@ -338,23 +464,33 @@ const schemas = {
338
464
  .optional()
339
465
  .describe("Raw metadata object forwarded as-is to the v1.1 API. Useful for fields not exposed as first-class params."),
340
466
  },
341
- "update-metadata-ui": {
467
+ submit: {
468
+ zipPath: z.string().describe("Absolute path to the ZIP file to upload and submit"),
342
469
  itemId: itemIdSchema,
343
- title: z.string().optional().describe("Store listing title"),
344
- summary: z.string().optional().describe("Store listing short summary"),
345
- description: z.string().optional().describe("Store listing long description"),
346
- category: z.string().optional().describe("Category label as shown in dashboard UI"),
347
- homepageUrl: z.string().optional().describe("Homepage URL"),
348
- supportUrl: z.string().optional().describe("Support URL"),
349
- storeIconPath: z.string().optional().describe("Absolute path to 128x128 store icon image"),
350
- accountIndex: z
470
+ publisherId: publisherIdSchema,
471
+ publishType: z
472
+ .enum(["DEFAULT_PUBLISH", "STAGED_PUBLISH"])
473
+ .optional()
474
+ .describe("DEFAULT_PUBLISH (default): publish immediately after approval. STAGED_PUBLISH: stage for manual publishing."),
475
+ deployPercentage: z
351
476
  .number()
352
477
  .int()
353
478
  .min(0)
354
- .max(9)
479
+ .max(100)
480
+ .optional()
481
+ .describe("Initial deploy percentage for staged rollout (0-100)."),
482
+ skipReview: z
483
+ .boolean()
484
+ .optional()
485
+ .describe("Attempt to skip review if the extension qualifies. Defaults to false."),
486
+ blockOnWarnings: z
487
+ .boolean()
488
+ .optional()
489
+ .describe("If true, block the publish when the submission has warnings. Defaults to false."),
490
+ preflight: z
491
+ .boolean()
355
492
  .optional()
356
- .describe("Google account index in dashboard URL (default: 0)"),
357
- headless: z.boolean().optional().describe("Run browser headless (default: false)"),
493
+ .describe("Verify the item exists before uploading (default: true). Set false to skip the pre-check."),
358
494
  },
359
495
  };
360
496
  const descriptions = {
@@ -364,8 +500,8 @@ const descriptions = {
364
500
  cancel: "Cancel a pending submission on Chrome Web Store. Can be used to cancel an item currently in review.",
365
501
  "deploy-percentage": "Set the published deploy percentage for staged rollout on Chrome Web Store. The new percentage must be higher than the current target. Only available for items with 10,000+ seven-day active users.",
366
502
  get: `Get the current metadata of a Chrome Web Store item (v1.1 API). Returns title, description, category, and other listing fields. Note: the v1.1 API is deprecated and will be removed after ${V1_SUNSET}.`,
367
- "update-metadata": `Update the store listing metadata of a Chrome Web Store item (v1.1 API). Supports common fields and a raw metadata payload. Note: the v1.1 API is deprecated and will be removed after ${V1_SUNSET}. Use 'update-metadata-ui' as the long-term alternative.`,
368
- "update-metadata-ui": "Update listing metadata via Chrome Web Store dashboard UI automation (Playwright). Use this when API metadata updates are not reflected, or as the primary metadata update method since the v1.1 API is deprecated.",
503
+ "update-metadata": `Update the store listing metadata of a Chrome Web Store item (v1.1 API). Supports common fields and a raw metadata payload. Note: the v1.1 API is deprecated and will be removed after ${V1_SUNSET}; after that, listing changes must be made in the Developer Dashboard.`,
504
+ submit: "One-shot submission: (optional preflight existence check) upload the ZIP verify the upload succeeded publish for review return the final status with a readable summary. Combines upload + publish + status and surfaces actionable errors at each step.",
369
505
  };
370
506
  // ── MCP Server ──
371
507
  const server = new McpServer({
@@ -383,7 +519,7 @@ server.registerTool("upload", { description: descriptions.upload, inputSchema: s
383
519
  method: "POST",
384
520
  headers: { "Content-Type": "application/zip" },
385
521
  body: new Uint8Array(zipData),
386
- });
522
+ }, 180_000);
387
523
  return formatResponse(result);
388
524
  }
389
525
  catch (e) {
@@ -395,16 +531,8 @@ server.registerTool("publish", { description: descriptions.publish, inputSchema:
395
531
  try {
396
532
  const id = resolveItemId(itemId);
397
533
  const pub = resolvePublisherId(publisherId);
398
- const url = `${API_BASE}/v2/publishers/${pub}/items/${id}:publish`;
399
- const body = {};
400
- if (publishType)
401
- body.publishType = publishType;
402
- if (deployPercentage !== undefined)
403
- body.deployInfos = [{ deployPercentage }];
404
- if (skipReview !== undefined)
405
- body.skipReview = skipReview;
406
- if (blockOnWarnings !== undefined)
407
- body.blockOnWarnings = blockOnWarnings;
534
+ const url = itemUrl(pub, id, "publish");
535
+ const body = buildPublishBody({ publishType, deployPercentage, skipReview, blockOnWarnings });
408
536
  const hasBody = Object.keys(body).length > 0;
409
537
  const result = await apiCall(url, {
410
538
  method: "POST",
@@ -423,9 +551,15 @@ server.registerTool("status", { description: descriptions.status, inputSchema: s
423
551
  try {
424
552
  const id = resolveItemId(itemId);
425
553
  const pub = resolvePublisherId(publisherId);
426
- const url = `${API_BASE}/v2/publishers/${pub}/items/${id}:fetchStatus`;
554
+ const url = itemUrl(pub, id, "fetchStatus");
427
555
  const result = await apiCall(url, { method: "GET" });
428
- return formatResponse(result);
556
+ const formatted = formatResponse(result);
557
+ if (result.ok) {
558
+ const summary = summarizeStatus(result.body);
559
+ if (summary)
560
+ return appendNote(formatted, summary);
561
+ }
562
+ return formatted;
429
563
  }
430
564
  catch (e) {
431
565
  return toolError(e);
@@ -436,7 +570,7 @@ server.registerTool("cancel", { description: descriptions.cancel, inputSchema: s
436
570
  try {
437
571
  const id = resolveItemId(itemId);
438
572
  const pub = resolvePublisherId(publisherId);
439
- const url = `${API_BASE}/v2/publishers/${pub}/items/${id}:cancelSubmission`;
573
+ const url = itemUrl(pub, id, "cancelSubmission");
440
574
  const result = await apiCall(url, { method: "POST" });
441
575
  return formatResponse(result);
442
576
  }
@@ -449,7 +583,7 @@ server.registerTool("deploy-percentage", { description: descriptions["deploy-per
449
583
  try {
450
584
  const id = resolveItemId(itemId);
451
585
  const pub = resolvePublisherId(publisherId);
452
- const url = `${API_BASE}/v2/publishers/${pub}/items/${id}:setPublishedDeployPercentage`;
586
+ const url = itemUrl(pub, id, "setPublishedDeployPercentage");
453
587
  const result = await apiCall(url, {
454
588
  method: "POST",
455
589
  headers: { "Content-Type": "application/json" },
@@ -464,6 +598,9 @@ server.registerTool("deploy-percentage", { description: descriptions["deploy-per
464
598
  // ── get (v1.1 — deprecated, sunset Oct 2026) ──
465
599
  server.registerTool("get", { description: descriptions.get, inputSchema: schemas.get }, async ({ itemId, projection }) => {
466
600
  try {
601
+ const guard = v1SunsetGuard();
602
+ if (guard)
603
+ return guard;
467
604
  const id = resolveItemId(itemId);
468
605
  const p = projection || "DRAFT";
469
606
  const url = `${V1_BASE}/items/${id}?projection=${encodeURIComponent(p)}`;
@@ -477,6 +614,9 @@ server.registerTool("get", { description: descriptions.get, inputSchema: schemas
477
614
  // ── update-metadata (v1.1 — deprecated, sunset Oct 2026) ──
478
615
  server.registerTool("update-metadata", { description: descriptions["update-metadata"], inputSchema: schemas["update-metadata"] }, async ({ itemId, title, summary, description, category, defaultLocale, homepageUrl, supportUrl, metadata }) => {
479
616
  try {
617
+ const guard = v1SunsetGuard();
618
+ if (guard)
619
+ return guard;
480
620
  const id = resolveItemId(itemId);
481
621
  const url = `${V1_BASE}/items/${id}`;
482
622
  const payload = { ...(metadata || {}) };
@@ -508,69 +648,129 @@ server.registerTool("update-metadata", { description: descriptions["update-metad
508
648
  return toolError(e);
509
649
  }
510
650
  });
511
- // ── update-metadata-ui (dashboard automation) ──
512
- server.registerTool("update-metadata-ui", { description: descriptions["update-metadata-ui"], inputSchema: schemas["update-metadata-ui"] }, async ({ itemId, title, summary, description, category, homepageUrl, supportUrl, storeIconPath, accountIndex, headless }) => {
651
+ // ── submit (one-shot: preflight → upload → publish → status) ──
652
+ server.registerTool("submit", { description: descriptions.submit, inputSchema: schemas.submit }, async ({ zipPath, itemId, publisherId, publishType, deployPercentage, skipReview, blockOnWarnings, preflight }) => {
513
653
  try {
514
654
  const id = resolveItemId(itemId);
515
- const idx = accountIndex ?? 0;
516
- const dashboardUrl = `https://chromewebstore.google.com/u/${idx}/dashboard/${id}/edit`;
517
- const hasAnyField = [title, summary, description, category, homepageUrl, supportUrl, storeIconPath].some((v) => typeof v === "string" && v.trim().length > 0);
518
- if (!hasAnyField) {
519
- throw new Error("No fields provided for UI update.");
520
- }
521
- const context = await chromium.launchPersistentContext(DASHBOARD_PROFILE_DIR, {
522
- channel: "chrome",
523
- headless: headless ?? false,
524
- });
655
+ const pub = resolvePublisherId(publisherId);
656
+ const steps = [];
657
+ // ZIP を先に読む(パス不正ならここで分かりやすく失敗)。
658
+ let zipData;
525
659
  try {
526
- const page = context.pages()[0] || (await context.newPage());
527
- await page.goto(dashboardUrl, { waitUntil: "domcontentloaded", timeout: 90_000 });
528
- await page.waitForTimeout(2500);
529
- if (page.url().includes("accounts.google.com")) {
530
- throw new Error(`Not signed in to Chrome Web Store dashboard. Open once with headless=false and sign in. Profile dir: ${DASHBOARD_PROFILE_DIR}`);
531
- }
532
- if (title?.trim()) {
533
- await fillTextFieldByLabel(page, ["Title", "제목", "Name", "이름"], title.trim());
534
- }
535
- if (summary?.trim()) {
536
- await fillTextFieldByLabel(page, ["Summary", "Short description", "요약", "짧은 설명"], summary.trim());
537
- }
538
- if (description?.trim()) {
539
- await fillTextFieldByLabel(page, ["Description", "설명"], description.trim());
540
- }
541
- if (homepageUrl?.trim()) {
542
- await fillTextFieldByLabel(page, ["Homepage", "홈페이지"], homepageUrl.trim());
543
- }
544
- if (supportUrl?.trim()) {
545
- await fillTextFieldByLabel(page, ["Support", "지원", "Help", "도움말"], supportUrl.trim());
546
- }
547
- if (storeIconPath?.trim()) {
548
- await uploadFileBySectionLabel(page, ["Store icon", "스토어 아이콘", "아이콘", "Icon"], storeIconPath.trim());
549
- }
550
- if (category?.trim()) {
551
- const categoryCombo = page.getByRole("combobox", { name: /category|카테고리/i }).first();
552
- if ((await categoryCombo.count()) > 0) {
553
- await categoryCombo.click();
554
- const option = page.getByRole("option", { name: new RegExp(escapeRegExp(category), "i") }).first();
555
- if ((await option.count()) > 0) {
556
- await option.click();
557
- }
558
- }
660
+ zipData = readFileSync(zipPath);
661
+ }
662
+ catch (e) {
663
+ return toolError(new Error(`Cannot read ZIP at '${zipPath}': ${errMsg(e)}`));
664
+ }
665
+ // 1) Preflight: アイテム存在確認(アップロード前に 404 を検出する)。
666
+ if (preflight !== false) {
667
+ const pre = await apiCall(itemUrl(pub, id, "fetchStatus"), {
668
+ method: "GET",
669
+ });
670
+ const preMissing = !pre.ok && (pre.status === 404 || pre.body.toLowerCase().includes("could not find handler"));
671
+ if (preMissing) {
672
+ const hint = interpretCwsError(pre.status, pre.body);
673
+ return {
674
+ content: [
675
+ {
676
+ type: "text",
677
+ text: `Preflight failed: item '${id}' was not found (HTTP ${pre.status}).` +
678
+ (hint ? `\n\n${hint}` : ""),
679
+ },
680
+ ],
681
+ isError: true,
682
+ };
559
683
  }
560
- await clickSaveButton(page);
684
+ steps.push({ step: "preflight", ok: pre.ok, detail: pre.ok ? "item exists" : `status ${pre.status} (proceeding)` });
685
+ }
686
+ // 2) Upload
687
+ const uploadRes = await apiCall(`${UPLOAD_BASE}/publishers/${pub}/items/${id}:upload`, {
688
+ method: "POST",
689
+ headers: { "Content-Type": "application/zip" },
690
+ body: new Uint8Array(zipData),
691
+ }, 180_000);
692
+ let uploadState;
693
+ let uploadItemError;
694
+ try {
695
+ const j = JSON.parse(uploadRes.body);
696
+ uploadState = typeof j.uploadState === "string" ? j.uploadState : undefined;
697
+ uploadItemError = j.itemError;
698
+ }
699
+ catch {
700
+ // non-JSON body — leave undefined
701
+ }
702
+ // upload は HTTP 200 でも uploadState=FAILURE / itemError で失敗していることがある。
703
+ // 「成功と確認できたときだけ次へ進む」方針: HTTP エラー・itemError(配列でもオブジェクトでも)・
704
+ // 既知の非 SUCCESS ステートは失敗扱いにし、壊れた版を黙って publish しない。
705
+ // ただし進行中(IN_PROGRESS / PROCESSING)と、ステート不明(非 JSON で HTTP 200)のときは中断しない。
706
+ const uploadPending = uploadState === "IN_PROGRESS" || uploadState === "PROCESSING";
707
+ const hasItemError = uploadItemError != null &&
708
+ (!Array.isArray(uploadItemError) || uploadItemError.length > 0);
709
+ const uploadFailed = !uploadRes.ok ||
710
+ hasItemError ||
711
+ (uploadState !== undefined && uploadState !== "SUCCESS" && !uploadPending);
712
+ steps.push({
713
+ step: "upload",
714
+ ok: !uploadFailed,
715
+ detail: `HTTP ${uploadRes.status}${uploadState ? `, uploadState=${uploadState}` : ""}`,
716
+ });
717
+ if (uploadFailed) {
718
+ const hint = interpretCwsError(uploadRes.status, uploadRes.body);
561
719
  return {
562
720
  content: [
563
721
  {
564
722
  type: "text",
565
- text: JSON.stringify({ ok: true, mode: "dashboard-ui", profileDir: DASHBOARD_PROFILE_DIR, url: page.url() }, null, 2),
723
+ text: `Upload failed (HTTP ${uploadRes.status}${uploadState ? `, uploadState=${uploadState}` : ""}): ${uploadRes.body}` +
724
+ (hint ? `\n\n${hint}` : ""),
566
725
  },
567
726
  ],
568
- isError: false,
727
+ isError: true,
569
728
  };
570
729
  }
571
- finally {
572
- await context.close();
730
+ // 3) Publish
731
+ const publishBody = buildPublishBody({ publishType, deployPercentage, skipReview, blockOnWarnings });
732
+ const hasBody = Object.keys(publishBody).length > 0;
733
+ const publishRes = await apiCall(itemUrl(pub, id, "publish"), {
734
+ method: "POST",
735
+ ...(hasBody
736
+ ? { headers: { "Content-Type": "application/json" }, body: JSON.stringify(publishBody) }
737
+ : {}),
738
+ });
739
+ steps.push({ step: "publish", ok: publishRes.ok, detail: `HTTP ${publishRes.status}` });
740
+ if (!publishRes.ok) {
741
+ const hint = interpretCwsError(publishRes.status, publishRes.body);
742
+ return {
743
+ content: [
744
+ {
745
+ type: "text",
746
+ text: `Upload succeeded, but publish failed (HTTP ${publishRes.status}): ${publishRes.body}` +
747
+ (hint ? `\n\n${hint}` : "") +
748
+ "\n\nNote: the new ZIP is already uploaded as a draft — no need to re-upload. " +
749
+ "Retry with the 'publish' tool alone; re-running 'submit' would upload the ZIP again unnecessarily.",
750
+ },
751
+ ],
752
+ isError: true,
753
+ };
573
754
  }
755
+ // 4) Final status
756
+ const statusRes = await apiCall(itemUrl(pub, id, "fetchStatus"), {
757
+ method: "GET",
758
+ });
759
+ steps.push({ step: "status", ok: statusRes.ok, detail: `HTTP ${statusRes.status}` });
760
+ const summary = statusRes.ok ? summarizeStatus(statusRes.body) : null;
761
+ const out = [
762
+ `✅ Submitted '${id}' for review.`,
763
+ "",
764
+ "Steps:",
765
+ ...steps.map((s) => ` ${s.ok ? "✓" : "✗"} ${s.step}${s.detail ? ` — ${s.detail}` : ""}`),
766
+ ];
767
+ if (summary)
768
+ out.push("", summary);
769
+ // 要約のキー名マッチが外れても拒否理由を取りこぼさないよう、生 status も併記する。
770
+ if (statusRes.ok)
771
+ out.push("", "Status response:", statusRes.body);
772
+ out.push("", "Publish response:", publishRes.body);
773
+ return { content: [{ type: "text", text: out.join("\n") }], isError: false };
574
774
  }
575
775
  catch (e) {
576
776
  return toolError(e);
@@ -583,40 +783,29 @@ server.registerResource("extension-status", new ResourceTemplate("cws://extensio
583
783
  mimeType: "application/json",
584
784
  }, async (uri, variables) => {
585
785
  const extensionId = String(variables.extensionId);
786
+ const encId = encodeURIComponent(extensionId);
586
787
  try {
587
788
  const pub = resolvePublisherId();
588
- const token = await getAccessToken();
589
- // v2 status the canonical source, must succeed.
590
- const statusRes = await fetch(`${API_BASE}/v2/publishers/${pub}/items/${extensionId}:fetchStatus`, {
591
- headers: { Authorization: `Bearer ${token}` },
592
- });
593
- const statusText = await statusRes.text();
594
- let statusData;
595
- try {
596
- statusData = JSON.parse(statusText);
597
- }
598
- catch {
599
- statusData = { raw: statusText };
600
- }
601
- // v1.1 metadata — optional, gracefully degrades once the v1.1 API is sunset.
602
- let metaData;
603
- try {
604
- const metaRes = await fetch(`${V1_BASE}/items/${extensionId}?projection=PUBLISHED`, {
605
- headers: { Authorization: `Bearer ${token}` },
606
- });
607
- const metaText = await metaRes.text();
789
+ // v2 status (canonical) と v1.1 metadata (optional) は独立なので並列取得。
790
+ // apiCall 経由でトークン付与・タイムアウト・401 無効化を tool 群と共有する。
791
+ const [statusRes, metaRes] = await Promise.all([
792
+ apiCall(itemUrl(pub, encId, "fetchStatus"), { method: "GET" }),
793
+ apiCall(`${V1_BASE}/items/${encId}?projection=PUBLISHED`, { method: "GET" }).catch((e) => ({ ok: false, status: 0, body: errMsg(e) })),
794
+ ]);
795
+ const parseBody = (raw) => {
608
796
  try {
609
- metaData = JSON.parse(metaText);
797
+ return JSON.parse(raw);
610
798
  }
611
799
  catch {
612
- metaData = { raw: metaText };
800
+ return { raw };
613
801
  }
614
- }
615
- catch (e) {
616
- metaData = {
617
- unavailable: `v1.1 metadata fetch failed (the v1.1 API is deprecated after ${V1_SUNSET}): ${errMsg(e)}`,
802
+ };
803
+ const statusData = parseBody(statusRes.body);
804
+ const metaData = metaRes.ok
805
+ ? parseBody(metaRes.body)
806
+ : {
807
+ unavailable: `v1.1 metadata fetch failed (the v1.1 API is deprecated after ${V1_SUNSET}): ${metaRes.body}`,
618
808
  };
619
- }
620
809
  const result = { extensionId, status: statusData, metadata: metaData };
621
810
  return {
622
811
  contents: [
@@ -651,11 +840,11 @@ server.registerPrompt("publish_extension", {
651
840
  Extension ID: ${extensionId}
652
841
  ZIP file: ${zipPath}${version ? `\nNew version: ${version}` : ""}
653
842
 
654
- Follow these steps using the available cws-mcp tools:
843
+ Tip: for a one-shot flow, the \`submit\` tool runs preflight → upload → publish → status in a single call. (A brand-new extension must first be created in the Developer Dashboard — the CWS API cannot create items.) The step-by-step flow below is the manual equivalent:
655
844
 
656
845
  1. **Upload the ZIP** — Use the \`upload\` tool with zipPath="${zipPath}" and itemId="${extensionId}" to upload the new build as a draft.
657
846
  2. **Verify upload** — Use the \`status\` tool to confirm the upload succeeded and the item is in DRAFT state.
658
- 3. **Check/update metadata** — Use the \`get\` tool (projection=DRAFT) to review current listing metadata. If anything needs updating (title, description, category), prefer \`update-metadata-ui\` (the v1.1-based \`update-metadata\` is deprecated and sunset ${V1_SUNSET}).
847
+ 3. **Check/update metadata** — Use the \`get\` tool (projection=DRAFT) to review current listing metadata. If text fields need updating (title, description, category), use \`update-metadata\` (v1.1, deprecated and sunset ${V1_SUNSET}); after the sunset, edit the listing in the Developer Dashboard.
659
848
  4. **Publish** — Use the \`publish\` tool to submit the draft for review. Optionally use publishType="STAGED_PUBLISH" for staged rollout, or skipReview=true if eligible.
660
849
  5. **Confirm submission** — Use the \`status\` tool again to confirm the item entered the review queue.
661
850
  6. **Optional staged rollout** — After approval, use \`deploy-percentage\` to gradually roll out (e.g., 10%, 50%, 100%).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1llum1n4t1/cws-mcp",
3
- "version": "1.4.2",
3
+ "version": "2.0.0",
4
4
  "mcpName": "io.github.1llum1n4t1s/cws",
5
5
  "description": "MCP server for Chrome Web Store extension management (V2 API)",
6
6
  "type": "module",
@@ -46,7 +46,6 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@modelcontextprotocol/sdk": "^1.29.0",
49
- "playwright": "^1.60.0",
50
49
  "zod": "^3.24.2"
51
50
  },
52
51
  "devDependencies": {