@mcohenchen/pingcode-mcp 0.0.0-stage → 0.3.2

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.
@@ -0,0 +1,81 @@
1
+ import { PingCodeError } from "../client/errors.js";
2
+ import { LONG_ID_RE } from "../client/refs.js";
3
+ import { getWorkItem } from "../pjm/index.js";
4
+ export function summarizeComment(c) {
5
+ const by = c?.created_by;
6
+ return {
7
+ id: c.id,
8
+ content: typeof c.content === "string" ? c.content : c.content == null ? "" : String(c.content),
9
+ is_deleted: c.is_deleted,
10
+ created_at: c.created_at,
11
+ updated_at: c.updated_at,
12
+ created_by: by ? { id: by.id, name: by.name, display_name: by.display_name } : null,
13
+ };
14
+ }
15
+ /**
16
+ * Deleted comments stay in list results with `is_deleted=1` and empty content. When the flag is
17
+ * absent (e.g. /v1/project/work_items/{id}/comments omits it), empty content without attachments
18
+ * is treated as deleted.
19
+ */
20
+ export function isDeletedComment(c) {
21
+ if (c.is_deleted !== undefined && c.is_deleted !== null)
22
+ return !!c.is_deleted;
23
+ return !c.content && !(Array.isArray(c.attachments) && c.attachments.length);
24
+ }
25
+ async function resolveWorkItemBrief(client, ref) {
26
+ const w = await getWorkItem(client, ref);
27
+ return { id: w.id, identifier: w.identifier, title: w.title };
28
+ }
29
+ const commentQuery = (workItemId) => ({ principal_type: "work_item", principal_id: workItemId });
30
+ export async function listWorkItemComments(client, workItemRef, opts = {}) {
31
+ const workItem = await resolveWorkItemBrief(client, workItemRef);
32
+ // /v1/comments carries is_deleted; /v1/project/work_items/{id}/comments does not.
33
+ const fetchPage = (pageIndex, pageSize) => client.get("/v1/comments", { ...commentQuery(workItem.id), page_index: pageIndex, page_size: pageSize });
34
+ const mark = (c) => {
35
+ const s = summarizeComment(c);
36
+ return isDeletedComment(c) ? { ...s, deleted: true } : s;
37
+ };
38
+ if (opts.includeDeleted && (opts.pageIndex !== undefined || opts.pageSize !== undefined)) {
39
+ const res = await fetchPage(opts.pageIndex, opts.pageSize);
40
+ const values = res?.values ?? [];
41
+ return {
42
+ work_item: workItem,
43
+ page_index: res?.page_index,
44
+ page_size: res?.page_size,
45
+ total: res?.total ?? values.length,
46
+ comments: values.map(mark),
47
+ };
48
+ }
49
+ // Filtering deleted rows (or no paging given): page through so the filtered total is accurate.
50
+ const pageSize = 100;
51
+ const all = [];
52
+ for (let pageIndex = 0; pageIndex < 50; pageIndex++) {
53
+ const res = await fetchPage(pageIndex, pageSize);
54
+ const values = res?.values ?? [];
55
+ all.push(...values);
56
+ const total = typeof res?.total === "number" ? res.total : undefined;
57
+ if (values.length < pageSize || (total !== undefined && all.length >= total))
58
+ break;
59
+ }
60
+ const kept = opts.includeDeleted ? all : all.filter((c) => !isDeletedComment(c));
61
+ return { work_item: workItem, total: kept.length, comments: kept.map(mark) };
62
+ }
63
+ export async function createWorkItemComment(client, workItemRef, content) {
64
+ if (!content.trim())
65
+ throw new PingCodeError("评论内容不能为空");
66
+ const workItem = await resolveWorkItemBrief(client, workItemRef);
67
+ const res = await client.post(`/v1/project/work_items/${workItem.id}/comments`, { content });
68
+ if (!res?.id)
69
+ throw new PingCodeError("创建评论的响应中没有 id 字段");
70
+ return { work_item: workItem, comment: summarizeComment(res) };
71
+ }
72
+ export async function deleteWorkItemComment(client, workItemRef, commentId) {
73
+ const id = commentId.trim();
74
+ if (!LONG_ID_RE.test(id))
75
+ throw new PingCodeError(`comment_id 应为 24 位十六进制 id: ${commentId}`);
76
+ const workItem = await resolveWorkItemBrief(client, workItemRef);
77
+ const res = await client.delete(`/v1/comments/${id}`, commentQuery(workItem.id));
78
+ const comment = res?.id ? summarizeComment(res) : { id, content: "" };
79
+ return { work_item: workItem, comment: { ...comment, is_deleted: comment.is_deleted ?? 1, deleted: true } };
80
+ }
81
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,354 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import dotenv from "dotenv";
6
+ import { PingCodeError } from "../client/errors.js";
7
+ // Load .env from the package root (so it works regardless of cwd, e.g. when
8
+ // launched by Claude Desktop), then from cwd. quiet: stdout is the MCP channel.
9
+ // Variables already present in the environment always win.
10
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
11
+ dotenv.config({ path: path.join(packageRoot, ".env"), quiet: true });
12
+ dotenv.config({ quiet: true });
13
+ export const DEFAULT_API_BASE_URL = "https://open.pingcode.com";
14
+ export const DEFAULT_PROFILE_NAME = "default";
15
+ const FIELD_ALIASES = {
16
+ name: ["name"],
17
+ apiBaseUrl: ["apiBaseUrl", "api_base_url"],
18
+ accessToken: ["accessToken", "access_token"],
19
+ webBaseUrl: ["webBaseUrl", "web_base_url"],
20
+ defaultSpaceIdentifier: ["defaultSpaceIdentifier", "default_space_identifier"],
21
+ };
22
+ const MAX_RETRIES_KEYS = ["maxRetries", "max_retries"];
23
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
24
+ const blankToUndef = (v) => {
25
+ const t = v?.trim();
26
+ return t ? t : undefined;
27
+ };
28
+ /** Parse a non-negative integer from a number or numeric string; undefined if invalid. */
29
+ export function parseNonNegativeInt(v) {
30
+ const n = typeof v === "number" ? v : typeof v === "string" && v.trim() ? Number(v.trim()) : NaN;
31
+ return Number.isInteger(n) && n >= 0 ? n : undefined;
32
+ }
33
+ function readString(obj, keys, where) {
34
+ for (const key of keys) {
35
+ const v = obj[key];
36
+ if (v === undefined || v === null)
37
+ continue;
38
+ if (typeof v !== "string")
39
+ throw new PingCodeError(`${where}: 字段 ${key} 必须是字符串`);
40
+ const t = v.trim();
41
+ if (t)
42
+ return t;
43
+ }
44
+ return undefined;
45
+ }
46
+ function parseRawProfile(value, where) {
47
+ if (!isObject(value))
48
+ throw new PingCodeError(`${where}: profile 必须是 JSON 对象`);
49
+ const out = {};
50
+ for (const [field, keys] of Object.entries(FIELD_ALIASES)) {
51
+ const v = readString(value, keys, where);
52
+ if (v !== undefined)
53
+ out[field] = v;
54
+ }
55
+ for (const key of MAX_RETRIES_KEYS) {
56
+ const v = value[key];
57
+ if (v === undefined || v === null || v === "")
58
+ continue;
59
+ const n = parseNonNegativeInt(v);
60
+ if (n === undefined)
61
+ throw new PingCodeError(`${where}: 字段 ${key} 必须是非负整数`);
62
+ out.maxRetries = n;
63
+ break;
64
+ }
65
+ return out;
66
+ }
67
+ /**
68
+ * Accepted shapes (both for PINGCODE_PROFILES and the config file):
69
+ * - [ {profile}, ... ]
70
+ * - { "defaultProfile": "x", "profiles": [ {profile}, ... ] }
71
+ * - { "defaultProfile": "x", "profiles": { "name": {profile}, ... } }
72
+ * - { profile } (a single profile object)
73
+ */
74
+ export function parseProfilesConfig(value, source) {
75
+ let defaultProfile;
76
+ let entries;
77
+ if (Array.isArray(value)) {
78
+ entries = value.map((v) => ({ value: v }));
79
+ }
80
+ else if (isObject(value)) {
81
+ defaultProfile = readString(value, ["defaultProfile", "default_profile"], source);
82
+ const p = value.profiles;
83
+ if (Array.isArray(p)) {
84
+ entries = p.map((v) => ({ value: v }));
85
+ }
86
+ else if (isObject(p)) {
87
+ entries = Object.entries(p).map(([key, v]) => ({ value: v, key }));
88
+ }
89
+ else if (p === undefined && FIELD_ALIASES.accessToken.some((k) => k in value)) {
90
+ entries = [{ value }];
91
+ }
92
+ else {
93
+ throw new PingCodeError(`${source}: 需要 "profiles" 数组/对象,或单个 profile 对象`);
94
+ }
95
+ }
96
+ else {
97
+ throw new PingCodeError(`${source}: 配置必须是 JSON 数组或对象`);
98
+ }
99
+ const profiles = entries.map(({ value: v, key }, i) => {
100
+ const where = `${source} profiles[${key ?? i}]`;
101
+ const prof = parseRawProfile(v, where);
102
+ prof.name ??= key;
103
+ if (!prof.name) {
104
+ if (entries.length > 1)
105
+ throw new PingCodeError(`${where}: 存在多个 profile 时每个都必须设置 name`);
106
+ prof.name = DEFAULT_PROFILE_NAME;
107
+ }
108
+ return prof;
109
+ });
110
+ const seen = new Set();
111
+ for (const p of profiles) {
112
+ if (seen.has(p.name))
113
+ throw new PingCodeError(`${source}: profile 名称重复: ${p.name}`);
114
+ seen.add(p.name);
115
+ }
116
+ return { defaultProfile, profiles };
117
+ }
118
+ function parseJson(text, source) {
119
+ try {
120
+ return JSON.parse(text);
121
+ }
122
+ catch (err) {
123
+ throw new PingCodeError(`${source} 不是合法的 JSON: ${err.message}`);
124
+ }
125
+ }
126
+ // ---------- URL helpers ----------
127
+ /** Normalize a base URL: trim, default to https:// when no scheme, drop trailing slashes. */
128
+ export function normalizeBaseUrl(input, where) {
129
+ let s = input.trim();
130
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(s))
131
+ s = `https://${s}`;
132
+ let url;
133
+ try {
134
+ url = new URL(s);
135
+ }
136
+ catch {
137
+ throw new PingCodeError(`${where}: 无效的 URL: ${input}`);
138
+ }
139
+ return (url.origin + url.pathname).replace(/\/+$/, "");
140
+ }
141
+ /** Lower-cased host (with port, if any) of a URL or bare host; undefined if it cannot be parsed. */
142
+ export function hostOf(urlOrHost) {
143
+ if (!urlOrHost?.trim())
144
+ return undefined;
145
+ let s = urlOrHost.trim();
146
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(s))
147
+ s = `https://${s}`;
148
+ try {
149
+ return new URL(s).host.toLowerCase() || undefined;
150
+ }
151
+ catch {
152
+ return undefined;
153
+ }
154
+ }
155
+ // ---------- loading ----------
156
+ export function expandHome(p) {
157
+ return p === "~" || p.startsWith("~/") || p.startsWith("~\\") ? path.join(os.homedir(), p.slice(1)) : p;
158
+ }
159
+ /** Path of the config file and whether it was set explicitly (PINGCODE_CONFIG). */
160
+ export function configFilePath(env = process.env) {
161
+ const override = blankToUndef(env.PINGCODE_CONFIG);
162
+ if (override)
163
+ return { path: path.resolve(expandHome(override)), explicit: true };
164
+ const base = blankToUndef(env.XDG_CONFIG_HOME) ?? path.join(os.homedir(), ".config");
165
+ return { path: path.join(base, "pingcode-mcp", "config.json"), explicit: false };
166
+ }
167
+ function finalizeProfile(p, source) {
168
+ const where = `profile "${p.name}" (${source})`;
169
+ if (!p.accessToken)
170
+ throw new PingCodeError(`${where} 缺少 accessToken`);
171
+ return {
172
+ name: p.name,
173
+ apiBaseUrl: normalizeBaseUrl(p.apiBaseUrl ?? DEFAULT_API_BASE_URL, `${where} apiBaseUrl`),
174
+ accessToken: p.accessToken,
175
+ webBaseUrl: p.webBaseUrl ? normalizeBaseUrl(p.webBaseUrl, `${where} webBaseUrl`) : undefined,
176
+ defaultSpaceIdentifier: p.defaultSpaceIdentifier,
177
+ maxRetries: p.maxRetries,
178
+ };
179
+ }
180
+ /**
181
+ * Load profiles. Sources, highest priority first:
182
+ * 1. PINGCODE_PROFILES (JSON)
183
+ * 2. config file (PINGCODE_CONFIG or ~/.config/pingcode-mcp/config.json)
184
+ * 3. legacy single-token env (PINGCODE_ACCESS_TOKEN …) — only when 1 and 2 define no profiles
185
+ * Profiles with the same name in 1 and 2 are merged field by field (1 wins).
186
+ * Default profile: PINGCODE_DEFAULT_PROFILE > defaultProfile in 1 > in 2 > profile named
187
+ * "default" > first profile.
188
+ */
189
+ export function loadProfiles(options = {}) {
190
+ const env = options.env ?? process.env;
191
+ const sources = [];
192
+ const warnings = [];
193
+ const merged = new Map();
194
+ let defaultFromJson;
195
+ const addAll = (cfg, source) => {
196
+ for (const p of cfg.profiles) {
197
+ const prev = merged.get(p.name);
198
+ if (!prev) {
199
+ merged.set(p.name, { profile: { ...p }, source });
200
+ }
201
+ else {
202
+ for (const [k, v] of Object.entries(p))
203
+ if (v !== undefined)
204
+ prev.profile[k] = v;
205
+ prev.source = `${prev.source} + ${source}`;
206
+ }
207
+ }
208
+ defaultFromJson = cfg.defaultProfile ?? defaultFromJson;
209
+ };
210
+ // 2. config file (lower priority, applied first so env JSON overrides it)
211
+ if (options.configPath !== false) {
212
+ const cfgPath = options.configPath !== undefined
213
+ ? { path: path.resolve(expandHome(options.configPath)), explicit: true }
214
+ : configFilePath(env);
215
+ let text;
216
+ try {
217
+ text = fs.readFileSync(cfgPath.path, "utf8");
218
+ }
219
+ catch (err) {
220
+ const code = err.code;
221
+ if (cfgPath.explicit || code !== "ENOENT") {
222
+ throw new PingCodeError(`无法读取配置文件 ${cfgPath.path}: ${err.message}`);
223
+ }
224
+ }
225
+ if (text !== undefined && text.trim()) {
226
+ const source = `config file ${cfgPath.path}`;
227
+ addAll(parseProfilesConfig(parseJson(text, source), source), source);
228
+ sources.push(source);
229
+ }
230
+ }
231
+ // 1. PINGCODE_PROFILES
232
+ const profilesJson = blankToUndef(env.PINGCODE_PROFILES);
233
+ if (profilesJson) {
234
+ const source = "env PINGCODE_PROFILES";
235
+ addAll(parseProfilesConfig(parseJson(profilesJson, source), source), source);
236
+ sources.push(source);
237
+ }
238
+ // 3. legacy single token
239
+ const legacyToken = blankToUndef(env.PINGCODE_ACCESS_TOKEN);
240
+ if (merged.size === 0 && legacyToken) {
241
+ const source = "env PINGCODE_ACCESS_TOKEN";
242
+ merged.set(DEFAULT_PROFILE_NAME, {
243
+ profile: {
244
+ name: DEFAULT_PROFILE_NAME,
245
+ accessToken: legacyToken,
246
+ apiBaseUrl: blankToUndef(env.PINGCODE_API_BASE_URL),
247
+ webBaseUrl: blankToUndef(env.PINGCODE_WEB_BASE_URL),
248
+ defaultSpaceIdentifier: blankToUndef(env.PINGCODE_DEFAULT_SPACE),
249
+ },
250
+ source,
251
+ });
252
+ sources.push(source);
253
+ }
254
+ else if (merged.size > 0 && legacyToken) {
255
+ warnings.push(`已从 ${sources.join(", ")} 加载 profile,忽略 PINGCODE_ACCESS_TOKEN / PINGCODE_API_BASE_URL / PINGCODE_WEB_BASE_URL / PINGCODE_DEFAULT_SPACE`);
256
+ }
257
+ const profiles = [...merged.values()].map(({ profile, source }) => finalizeProfile(profile, source));
258
+ let defaultProfile;
259
+ const requested = blankToUndef(env.PINGCODE_DEFAULT_PROFILE) ?? defaultFromJson;
260
+ if (requested) {
261
+ const found = findByName(profiles, requested);
262
+ if (!found) {
263
+ throw new PingCodeError(`默认 profile "${requested}" 不存在。${describeProfiles(profiles)}`);
264
+ }
265
+ defaultProfile = found.name;
266
+ }
267
+ else if (profiles.length) {
268
+ defaultProfile = (findByName(profiles, DEFAULT_PROFILE_NAME) ?? profiles[0]).name;
269
+ }
270
+ return { profiles, defaultProfile, sources, warnings };
271
+ }
272
+ let cached;
273
+ /** Profiles from the real environment, loaded once and cached. */
274
+ export function getProfiles() {
275
+ return (cached ??= loadProfiles());
276
+ }
277
+ /** Drop the cached profile set (next call re-reads env / config file). */
278
+ export function resetProfilesCache() {
279
+ cached = undefined;
280
+ }
281
+ // ---------- resolving ----------
282
+ function findByName(profiles, name) {
283
+ const exact = profiles.find((p) => p.name === name);
284
+ if (exact)
285
+ return exact;
286
+ const ci = profiles.filter((p) => p.name.toLowerCase() === name.toLowerCase());
287
+ return ci.length === 1 ? ci[0] : undefined;
288
+ }
289
+ /** Bulleted list of profiles for error messages. */
290
+ export function describeProfiles(profiles, defaultName) {
291
+ if (!profiles.length)
292
+ return "当前没有配置任何 profile。";
293
+ const lines = profiles.map((p) => ` - ${p.name}${p.name === defaultName ? " (默认)" : ""}: webBaseUrl=${p.webBaseUrl ?? "(未设置)"}, apiBaseUrl=${p.apiBaseUrl}`);
294
+ return `可用 profile:\n${lines.join("\n")}`;
295
+ }
296
+ function noProfileHelp() {
297
+ return (`未配置任何 PingCode profile。请运行 \`pingcode-mcp init\` 生成配置文件 ${configFilePath().path},` +
298
+ "填入 accessToken 后重启 MCP 客户端(客户端只需配置 command: pingcode-mcp)。" +
299
+ "本机开发也可用 PINGCODE_ACCESS_TOKEN 环境变量。");
300
+ }
301
+ /** Resolve a profile by name, or the default profile when name is omitted. */
302
+ export function resolveProfile(name, set = getProfiles()) {
303
+ if (!set.profiles.length)
304
+ throw new PingCodeError(noProfileHelp());
305
+ const wanted = blankToUndef(name);
306
+ if (!wanted)
307
+ return findByName(set.profiles, set.defaultProfile);
308
+ const found = findByName(set.profiles, wanted);
309
+ if (!found) {
310
+ throw new PingCodeError(`未知 profile "${wanted}"。${describeProfiles(set.profiles, set.defaultProfile)}`);
311
+ }
312
+ return found;
313
+ }
314
+ /** Profiles whose webBaseUrl host equals the given host (case-insensitive). */
315
+ export function profilesForHost(host, set = getProfiles()) {
316
+ const h = hostOf(host);
317
+ if (!h)
318
+ return [];
319
+ return set.profiles.filter((p) => hostOf(p.webBaseUrl) === h);
320
+ }
321
+ /**
322
+ * Pick the profile for a request.
323
+ * - explicit `profile` always wins
324
+ * - otherwise, if a URL host is known, the profile whose webBaseUrl host matches it
325
+ * (several matches → the default profile if it is one of them, else an error;
326
+ * no match → error, except a single profile without webBaseUrl is used as is)
327
+ * - otherwise the default profile
328
+ */
329
+ export function selectProfile(opts, set = getProfiles()) {
330
+ if (blankToUndef(opts.profile) || !opts.host)
331
+ return resolveProfile(opts.profile, set);
332
+ if (!set.profiles.length)
333
+ throw new PingCodeError(noProfileHelp());
334
+ const host = hostOf(opts.host) ?? opts.host;
335
+ const matches = profilesForHost(host, set);
336
+ if (matches.length === 1)
337
+ return matches[0];
338
+ if (matches.length > 1) {
339
+ const def = matches.find((p) => p.name === set.defaultProfile);
340
+ if (def)
341
+ return def;
342
+ throw new PingCodeError(`有多个 profile 的 webBaseUrl 匹配主机 ${host}: ${matches.map((p) => p.name).join(", ")}。请通过 profile 参数指定。\n` +
343
+ describeProfiles(set.profiles, set.defaultProfile));
344
+ }
345
+ if (set.profiles.length === 1 && !set.profiles[0].webBaseUrl)
346
+ return set.profiles[0];
347
+ throw new PingCodeError(`没有 profile 的 webBaseUrl 匹配主机 ${host}。请为对应 profile 配置 webBaseUrl,或通过 profile 参数指定。\n` +
348
+ describeProfiles(set.profiles, set.defaultProfile));
349
+ }
350
+ /** Mask a token for display: keeps only the last 4 characters. */
351
+ export function maskToken(token) {
352
+ return token.length > 8 ? `***${token.slice(-4)}` : "***";
353
+ }
354
+ //# sourceMappingURL=index.js.map
package/dist/index.js ADDED
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { configFilePath, getProfiles } from "./config/index.js";
6
+ import { initConfig, noProfileHint } from "./init.js";
7
+ import { registerAllTools } from "./tools/register.js";
8
+ const { version } = createRequire(import.meta.url)("../package.json");
9
+ const arg = process.argv[2];
10
+ if (arg === "--version" || arg === "-v") {
11
+ console.log(version);
12
+ process.exit(0);
13
+ }
14
+ if (arg === "--help" || arg === "-h") {
15
+ console.log(`pingcode-mcp ${version} — PingCode MCP server (stdio)
16
+
17
+ Usage:
18
+ pingcode-mcp start the MCP server (run from an MCP client)
19
+ pingcode-mcp init [--force] write a config template to the config file
20
+ pingcode-mcp --version | --help
21
+
22
+ Configuration (recommended): ${configFilePath().path}
23
+ { "defaultProfile": "demo",
24
+ "profiles": [ { "name": "demo", "webBaseUrl": "https://your-tenant.pingcode.com",
25
+ "accessToken": "pcpat_...", "defaultSpaceIdentifier": "DEMO" } ] }
26
+ The MCP client then only needs "command": "pingcode-mcp" (no env).
27
+
28
+ Environment (optional):
29
+ PINGCODE_CONFIG config file path override
30
+ PINGCODE_DEFAULT_PROFILE default profile name override
31
+ PINGCODE_ACCESS_TOKEN single token for local development
32
+ (+ PINGCODE_API_BASE_URL, PINGCODE_WEB_BASE_URL, PINGCODE_DEFAULT_SPACE)
33
+ PINGCODE_PROFILES advanced: profiles JSON, overrides the config file (not recommended in MCP client env)`);
34
+ process.exit(0);
35
+ }
36
+ if (arg === "init") {
37
+ const extra = process.argv.slice(3).filter((a) => a !== "--force" && a !== "-f");
38
+ if (extra.length) {
39
+ console.error(`pingcode-mcp init: unknown argument(s): ${extra.join(" ")}\nUsage: pingcode-mcp init [--force]`);
40
+ process.exit(2);
41
+ }
42
+ try {
43
+ const res = initConfig({ force: process.argv.includes("--force") || process.argv.includes("-f") });
44
+ (res.ok ? console.log : console.error)(res.message);
45
+ process.exit(res.ok ? 0 : 1);
46
+ }
47
+ catch (err) {
48
+ console.error(`pingcode-mcp init: ${err.message}`);
49
+ process.exit(1);
50
+ }
51
+ }
52
+ const server = new McpServer({ name: "pingcode-mcp", version });
53
+ registerAllTools(server);
54
+ // Surface config problems early on stderr (stdout is the MCP channel); tools report them too.
55
+ try {
56
+ const set = getProfiles();
57
+ for (const w of set.warnings)
58
+ console.error(`pingcode-mcp: ${w}`);
59
+ if (!set.profiles.length)
60
+ console.error(noProfileHint());
61
+ }
62
+ catch (err) {
63
+ console.error(`pingcode-mcp: 配置错误: ${err.message}`);
64
+ }
65
+ const transport = new StdioServerTransport();
66
+ await server.connect(transport);
67
+ console.error(`pingcode-mcp ${version} running on stdio`);
68
+ //# sourceMappingURL=index.js.map
package/dist/init.js ADDED
@@ -0,0 +1,60 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { configFilePath } from "./config/index.js";
4
+ /** Template written by `pingcode-mcp init` (placeholder token only). */
5
+ export const CONFIG_TEMPLATE = {
6
+ defaultProfile: "demo",
7
+ profiles: [
8
+ {
9
+ name: "demo",
10
+ apiBaseUrl: "https://open.pingcode.com",
11
+ webBaseUrl: "https://your-tenant.pingcode.com",
12
+ accessToken: "pcpat_REPLACE_ME",
13
+ defaultSpaceIdentifier: "DEMO",
14
+ },
15
+ ],
16
+ };
17
+ export const CONFIG_TEMPLATE_TEXT = JSON.stringify(CONFIG_TEMPLATE, null, 2) + "\n";
18
+ /**
19
+ * Write the config template to PINGCODE_CONFIG or ~/.config/pingcode-mcp/config.json.
20
+ * Refuses to overwrite an existing file unless `force` is set.
21
+ */
22
+ export function initConfig(opts = {}) {
23
+ const file = configFilePath(opts.env ?? process.env).path;
24
+ if (fs.existsSync(file) && !opts.force) {
25
+ return {
26
+ ok: false,
27
+ path: file,
28
+ message: `配置文件已存在: ${file}\n未做任何修改。如需用模板覆盖,请运行: pingcode-mcp init --force`,
29
+ };
30
+ }
31
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
32
+ fs.writeFileSync(file, CONFIG_TEMPLATE_TEXT, { mode: 0o600 });
33
+ try {
34
+ fs.chmodSync(file, 0o600); // mode above only applies to newly created files
35
+ }
36
+ catch {
37
+ // e.g. Windows — ignore
38
+ }
39
+ return {
40
+ ok: true,
41
+ path: file,
42
+ message: `已生成配置文件: ${file}
43
+
44
+ 下一步:
45
+ 1. 编辑该文件:把 accessToken 换成你的 PingCode 访问令牌,webBaseUrl 换成租户地址
46
+ (多个租户就在 profiles 数组里追加条目)
47
+ 2. 限制权限: chmod 600 ${file}
48
+ 3. MCP 客户端只需配置 "command": "pingcode-mcp"(不需要 env),例如:
49
+ claude mcp add pingcode -- pingcode-mcp
50
+ 4. 验证: 在客户端调用 pingcode_check_auth`,
51
+ };
52
+ }
53
+ /** Short stderr hint shown once at startup when no profile is configured. */
54
+ export function noProfileHint(env = process.env) {
55
+ const file = configFilePath(env).path;
56
+ const minimal = JSON.stringify({ profiles: [{ name: "demo", webBaseUrl: "https://your-tenant.pingcode.com", accessToken: "pcpat_REPLACE_ME" }] }, null, 2);
57
+ return `pingcode-mcp: 未配置任何 profile。运行 \`pingcode-mcp init\` 生成配置文件,或手动创建 ${file}:
58
+ ${minimal}`;
59
+ }
60
+ //# sourceMappingURL=init.js.map