@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,132 @@
1
+ import { PingCodeError } from "../client/errors.js";
2
+ import { HOST_PREFIX_RE, LONG_ID_RE, SCHEME_RE, decode } from "../client/refs.js";
3
+ /** Parse a page URL (absolute, host/path, or relative path) or a bare short/long id. */
4
+ export function parsePageRef(input) {
5
+ const s = input.trim();
6
+ if (!s)
7
+ throw new PingCodeError("url_or_id 不能为空");
8
+ let host;
9
+ let pathname = s;
10
+ if (SCHEME_RE.test(s) || HOST_PREFIX_RE.test(s)) {
11
+ try {
12
+ const u = new URL(SCHEME_RE.test(s) ? s : `https://${s}`);
13
+ host = u.host.toLowerCase() || undefined;
14
+ pathname = u.pathname;
15
+ }
16
+ catch {
17
+ throw new PingCodeError(`无法解析 URL: ${input}`);
18
+ }
19
+ }
20
+ else {
21
+ pathname = s.replace(/[?#].*$/, "");
22
+ }
23
+ const m = pathname.match(/\/pages\/([^/?#]+)/);
24
+ if (m) {
25
+ const ref = { pageId: decode(m[1]) };
26
+ if (host)
27
+ ref.host = host;
28
+ const sp = pathname.match(/\/spaces\/([^/?#]+)\/pages\//);
29
+ if (sp) {
30
+ const space = decode(sp[1]);
31
+ if (LONG_ID_RE.test(space))
32
+ ref.spaceId = space;
33
+ else
34
+ ref.spaceIdentifier = space;
35
+ }
36
+ return ref;
37
+ }
38
+ if (!host && /^[A-Za-z0-9_-]+$/.test(s))
39
+ return { pageId: s };
40
+ throw new PingCodeError(`无法从输入中解析页面 ID: ${input}`);
41
+ }
42
+ export async function readWikiPage(client, urlOrRef) {
43
+ const pageRef = typeof urlOrRef === "string" ? parsePageRef(urlOrRef) : urlOrRef;
44
+ const ref = pageRef.pageId;
45
+ let page;
46
+ if (LONG_ID_RE.test(ref)) {
47
+ page = await client.get(`/v1/wiki/pages/${ref}`).catch(() => undefined);
48
+ }
49
+ else {
50
+ page = await client.get(`/v1/wiki/pages/${encodeURIComponent(ref)}`);
51
+ if (!page?.id)
52
+ throw new PingCodeError(`页面 ${ref} 的响应中没有 id 字段`);
53
+ }
54
+ const longId = page?.id ?? ref;
55
+ const content = await client.get(`/v1/wiki/pages/${longId}/content`, { format_type: "markdown" });
56
+ const markdown = typeof content === "string"
57
+ ? content
58
+ : content?.content ?? content?.markdown ?? content?.body ?? JSON.stringify(content);
59
+ const space = page?.space
60
+ ? { id: page.space.id, name: page.space.name, identifier: page.space.identifier }
61
+ : pageRef.spaceIdentifier || pageRef.spaceId
62
+ ? { id: pageRef.spaceId, identifier: pageRef.spaceIdentifier }
63
+ : undefined;
64
+ return {
65
+ title: page?.name ?? page?.title ?? content?.title,
66
+ id: longId,
67
+ short_id: page?.short_id ?? (LONG_ID_RE.test(ref) ? undefined : ref),
68
+ html_url: page?.html_url,
69
+ space,
70
+ markdown,
71
+ };
72
+ }
73
+ export function listSpaces(client, pageIndex, pageSize) {
74
+ return client.get("/v1/wiki/spaces", { page_index: pageIndex, page_size: pageSize });
75
+ }
76
+ export function listPages(client, spaceId, pageIndex, pageSize) {
77
+ return client.get("/v1/wiki/pages", { space_id: spaceId, page_index: pageIndex, page_size: pageSize });
78
+ }
79
+ const SPACE_CACHE_TTL_MS = 5 * 60_000;
80
+ const spaceCache = new WeakMap();
81
+ /** All wiki spaces visible to the client (paged through; cached for a few minutes). */
82
+ export async function listAllSpaces(client, refresh = false) {
83
+ const hit = spaceCache.get(client);
84
+ if (hit && !refresh && Date.now() - hit.at < SPACE_CACHE_TTL_MS)
85
+ return hit.spaces;
86
+ const pageSize = 100;
87
+ const spaces = [];
88
+ for (let pageIndex = 0; pageIndex < 100; pageIndex++) {
89
+ const res = await listSpaces(client, pageIndex, pageSize);
90
+ const values = res?.values ?? [];
91
+ for (const s of values)
92
+ spaces.push({ id: s.id, name: s.name, identifier: s.identifier });
93
+ const total = typeof res?.total === "number" ? res.total : undefined;
94
+ if (values.length < pageSize || (total !== undefined && spaces.length >= total))
95
+ break;
96
+ }
97
+ spaceCache.set(client, { at: Date.now(), spaces });
98
+ return spaces;
99
+ }
100
+ function findSpace(spaces, ref) {
101
+ const lower = ref.toLowerCase();
102
+ return (spaces.find((s) => s.identifier === ref) ??
103
+ spaces.find((s) => s.identifier?.toLowerCase() === lower) ??
104
+ (() => {
105
+ const byName = spaces.filter((s) => s.name === ref);
106
+ return byName.length === 1 ? byName[0] : undefined;
107
+ })());
108
+ }
109
+ /**
110
+ * Resolve a space reference to a space id. A 24-hex id is returned as is; anything else is
111
+ * matched against space identifiers (case-insensitive, e.g. DEMO), then exact space names.
112
+ */
113
+ export async function resolveSpaceId(client, spaceRef) {
114
+ const ref = spaceRef.trim();
115
+ if (!ref)
116
+ throw new PingCodeError("空间标识不能为空");
117
+ if (LONG_ID_RE.test(ref))
118
+ return ref;
119
+ let spaces = await listAllSpaces(client);
120
+ let found = findSpace(spaces, ref);
121
+ if (!found) {
122
+ // The cache may predate a newly created space; retry once with fresh data.
123
+ spaces = await listAllSpaces(client, true);
124
+ found = findSpace(spaces, ref);
125
+ }
126
+ if (!found) {
127
+ const available = spaces.map((s) => `${s.identifier ?? "?"} (${s.name ?? ""})`).join(", ") || "(无)";
128
+ throw new PingCodeError(`profile "${client.profile.name}" 下找不到标识为 "${ref}" 的知识库空间。可用空间: ${available}`);
129
+ }
130
+ return found.id;
131
+ }
132
+ //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@mcohenchen/pingcode-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.2",
4
+ "description": "MCP server for PingCode (wiki, projects, work items, comments, sprints) with multi-tenant profiles",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "pingcode-mcp": "dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc && node -e \"require('fs').chmodSync('dist/index.js',0o755)\"",
12
+ "start": "node dist/index.js",
13
+ "test:unit": "tsx scripts/unit/profiles.ts && tsx scripts/unit/ratelimit.ts && tsx scripts/unit/pjm.ts && tsx scripts/unit/comments.ts",
14
+ "selftest": "tsx scripts/selftest.ts",
15
+ "test": "npm run test:unit && npm run selftest",
16
+ "prepublishOnly": "npm run build && npm run test:unit"
17
+ },
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "license": "MIT",
22
+ "dependencies": {
23
+ "@modelcontextprotocol/sdk": "^1.32.2",
24
+ "dotenv": "^18.0.9",
25
+ "zod": "^4.6.7"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^20.19.43",
29
+ "tsx": "^4.23.15",
30
+ "typescript": "^7.0.2"
31
+ },
32
+ "files": [
33
+ "dist/**/*.js",
34
+ "README.md",
35
+ ".env.example",
36
+ "CHANGELOG.md"
37
+ ],
38
+ "keywords": [
39
+ "mcp",
40
+ "pingcode",
41
+ "wiki",
42
+ "project",
43
+ "work-item",
44
+ "sprint",
45
+ "model-context-protocol"
46
+ ],
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/xl495/pingcode-mcp.git"
50
+ },
51
+ "bugs": {
52
+ "url": "https://github.com/xl495/pingcode-mcp/issues"
53
+ },
54
+ "homepage": "https://github.com/xl495/pingcode-mcp#readme",
55
+ "publishConfig": {
56
+ "access": "public"
57
+ }
58
+ }