@1llum1n4t1/cws-mcp 1.4.3 → 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 +5 -21
- package/README.md +6 -21
- package/dist/index.js +393 -204
- package/package.json +1 -2
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`)가
|
|
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에는 메타데이터 읽기/쓰기 엔드포인트가 없어 이 도구들이 브릿지 역할을 합니다.
|
|
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
|
-
- **"
|
|
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
|
-
| `
|
|
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`),
|
|
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.
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 —
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
];
|
|
239
|
-
|
|
240
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
467
|
+
submit: {
|
|
468
|
+
zipPath: z.string().describe("Absolute path to the ZIP file to upload and submit"),
|
|
342
469
|
itemId: itemIdSchema,
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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(
|
|
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("
|
|
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}
|
|
368
|
-
"
|
|
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 =
|
|
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 =
|
|
554
|
+
const url = itemUrl(pub, id, "fetchStatus");
|
|
427
555
|
const result = await apiCall(url, { method: "GET" });
|
|
428
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
// ──
|
|
512
|
-
server.registerTool("
|
|
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
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
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:
|
|
723
|
+
text: `Upload failed (HTTP ${uploadRes.status}${uploadState ? `, uploadState=${uploadState}` : ""}): ${uploadRes.body}` +
|
|
724
|
+
(hint ? `\n\n${hint}` : ""),
|
|
566
725
|
},
|
|
567
726
|
],
|
|
568
|
-
isError:
|
|
727
|
+
isError: true,
|
|
569
728
|
};
|
|
570
729
|
}
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
589
|
-
//
|
|
590
|
-
const statusRes = await
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
797
|
+
return JSON.parse(raw);
|
|
610
798
|
}
|
|
611
799
|
catch {
|
|
612
|
-
|
|
800
|
+
return { raw };
|
|
613
801
|
}
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
|
|
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
|
|
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": "
|
|
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": {
|