@zereight/mcp-gitlab 2.1.41 → 2.1.43
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 +3 -3
- package/README.md +9 -3
- package/README.zh-CN.md +3 -3
- package/build/downloads/proxy.js +3 -2
- package/build/index.js +322 -46
- package/build/oauth-proxy.js +2 -2
- package/build/oauth.js +2 -5
- package/build/schemas.js +91 -7
- package/build/scripts/check-skill-sync.js +1 -0
- package/build/scripts/generate-tool-docs.js +6 -1
- package/build/test/draft-note-schema.test.js +44 -0
- package/build/test/dynamic-api-url-allowlist.test.js +18 -1
- package/build/test/test-toolset-filtering.js +2 -0
- package/build/test/test-vulnerabilities.js +399 -0
- package/build/test/utils/bulk-publish-options.test.js +32 -0
- package/build/test/utils/gitlab-version-gate.test.js +74 -0
- package/build/tools/registry.js +35 -2
- package/build/utils/bulk-publish-options.js +21 -0
- package/build/utils/gitlab-version-gate.js +47 -0
- package/build/utils/logger.js +51 -0
- package/package.json +1 -1
package/README.ko.md
CHANGED
|
@@ -80,7 +80,7 @@ npm install -g @zereight/mcp-gitlab
|
|
|
80
80
|
|
|
81
81
|
예시는 기존 `mcp-gitlab`보다 충돌 가능성이 낮은 `zereight-mcp-gitlab` 별칭을 사용합니다. MCP 클라이언트가 찾지 못하면 `which zereight-mcp-gitlab`의 절대 경로를 사용하세요.
|
|
82
82
|
|
|
83
|
-
전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.
|
|
83
|
+
전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.42`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
|
|
84
84
|
|
|
85
85
|
#### CLI 인자 사용하기(환경 변수 문제가 있는 클라이언트용)
|
|
86
86
|
|
|
@@ -479,7 +479,7 @@ node build/index.js
|
|
|
479
479
|
AI 에이전트가 skill/instruction 로딩을 지원하는 경우(Claude Code, GitHub Copilot, Cursor 등), [`skills/gitlab-mcp/`](./skills/gitlab-mcp/)에 미리 작성된 skill 파일을 사용할 수 있습니다.
|
|
480
480
|
|
|
481
481
|
- **[SKILL.md](./skills/gitlab-mcp/SKILL.md)** — 도구셋 개요, 핵심 워크플로우, 파라미터 힌트를 담은 핵심 가이드
|
|
482
|
-
- **[reference/](./skills/gitlab-mcp/reference/)** — 코드 리뷰, 머지 리퀘스트, 이슈,
|
|
482
|
+
- **[reference/](./skills/gitlab-mcp/reference/)** — 코드 리뷰, 머지 리퀘스트, 이슈, 파이프라인, 취약점 트리아지용 상세 워크플로우 문서
|
|
483
483
|
|
|
484
484
|
`skills` CLI로 설치:
|
|
485
485
|
|
|
@@ -491,7 +491,7 @@ AI 클라이언트에 skill 디렉터리를 등록하면 전체 ListTools 응답
|
|
|
491
491
|
|
|
492
492
|
## 도구 🛠️
|
|
493
493
|
|
|
494
|
-
전체 도구 목록은 영어 README의 [Tools 섹션](./README.md#tools-%EF%B8%8F)을 참고하세요. 현재 서버는 머지 리퀘스트, 이슈, 파이프라인, 배포, 환경, 아티팩트, 마일스톤, 위키, 저장소, 릴리스, 사용자, 이벤트, work item, 웹훅, 코드 검색, GraphQL 실행 도구를 제공합니다.
|
|
494
|
+
전체 도구 목록은 영어 README의 [Tools 섹션](./README.md#tools-%EF%B8%8F)을 참고하세요. 현재 서버는 머지 리퀘스트, 이슈, 파이프라인, 배포, 환경, 아티팩트, 마일스톤, 위키, 저장소, 릴리스, 사용자, 이벤트, work item, 웹훅, 코드 검색, CI/CD 변수, dependency proxy, 취약점 트리아지, GraphQL 실행 도구를 제공합니다.
|
|
495
495
|
|
|
496
496
|
### Wiki 페이지 제목과 slug
|
|
497
497
|
|
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# GitLab MCP Server
|
|
2
2
|
|
|
3
|
+
[](https://mcptoplist.com/server/io.github.zereight%2Fgitlab-mcp)
|
|
4
|
+
|
|
3
5
|
[English](./README.md) | [한국어](./README.ko.md) | [简体中文](./README.zh-CN.md)
|
|
4
6
|
|
|
5
7
|
📖 **[Documentation →](https://zereight.github.io/gitlab-mcp/)** Setup guides, environment variables, and the full tool reference live on the hosted docs site.
|
|
@@ -80,7 +82,7 @@ npm install -g @zereight/mcp-gitlab
|
|
|
80
82
|
|
|
81
83
|
The examples use `zereight-mcp-gitlab`, a less collision-prone alias for the legacy `mcp-gitlab` binary. If your MCP client cannot find it, use the absolute path from `which zereight-mcp-gitlab`.
|
|
82
84
|
|
|
83
|
-
No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.1.
|
|
85
|
+
No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.1.42`. If you always want the newest release, use `npx -y @zereight/mcp-gitlab@latest` instead. The server prints a notice to stderr on startup when a newer version is available (disable with `GITLAB_DISABLE_VERSION_CHECK=true`).
|
|
84
86
|
|
|
85
87
|
#### Using CLI Arguments (for clients with env var issues)
|
|
86
88
|
|
|
@@ -523,7 +525,7 @@ No `headers` field is needed — Claude.ai obtains the token via OAuth automatic
|
|
|
523
525
|
Pre-built skill files are available in [`skills/gitlab-mcp/`](./skills/gitlab-mcp/) for AI agents that support skill/instruction loading (Claude Code, GitHub Copilot, Cursor, etc.).
|
|
524
526
|
|
|
525
527
|
- **[SKILL.md](./skills/gitlab-mcp/SKILL.md)** — Core guide (~800 tokens) with toolset overview, key workflows, and parameter hints
|
|
526
|
-
- **[reference/](./skills/gitlab-mcp/reference/)** — Detailed workflow docs for code review, merge requests, issues, and
|
|
528
|
+
- **[reference/](./skills/gitlab-mcp/reference/)** — Detailed workflow docs for code review, merge requests, issues, pipelines, and vulnerability triage
|
|
527
529
|
|
|
528
530
|
Install with the `skills` CLI:
|
|
529
531
|
|
|
@@ -751,7 +753,11 @@ Register the skill directory in your AI client to get optimal tool usage guidanc
|
|
|
751
753
|
209. `update_dependency_proxy_settings` - Update dependency proxy settings for a group (enable/disable, credentials for authenticated Docker Hub pulls)
|
|
752
754
|
210. `list_dependency_proxy_blobs` - List cached dependency proxy blobs for a group with cursor-based pagination
|
|
753
755
|
211. `purge_dependency_proxy_cache` - Schedule purge of all cached dependency proxy blobs for a group
|
|
754
|
-
212. `
|
|
756
|
+
212. `list_project_vulnerabilities` - List vulnerabilities for a project with optional state, severity, and report type filters (GraphQL-backed, cursor pagination)
|
|
757
|
+
213. `get_vulnerability` - Get full details of a specific vulnerability
|
|
758
|
+
214. `dismiss_vulnerability` - Dismiss a vulnerability with a reason (acceptable_risk, false_positive, used_in_tests, mitigating_control, not_applicable) and optional comment
|
|
759
|
+
215. `confirm_vulnerability` - Confirm a vulnerability as a real finding requiring remediation
|
|
760
|
+
216. `discover_tools` - Discover and activate additional tool categories for this session. Available categories: merge_requests, issues, repositories, branches, projects, labels, ci, groups, pipelines, milestones, wiki, releases, tags, users, workitems, webhooks, search, variables, dependency_proxy, vulnerabilities. Already-active categories are listed in the response.
|
|
755
761
|
|
|
756
762
|
<!-- TOOLS-END -->
|
|
757
763
|
|
package/README.zh-CN.md
CHANGED
|
@@ -80,7 +80,7 @@ npm install -g @zereight/mcp-gitlab
|
|
|
80
80
|
|
|
81
81
|
示例使用 `zereight-mcp-gitlab`,这是比旧的 `mcp-gitlab` 更不容易冲突的别名。如果 MCP 客户端找不到它,请使用 `which zereight-mcp-gitlab` 输出的绝对路径。
|
|
82
82
|
|
|
83
|
-
如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.
|
|
83
|
+
如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.42`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
|
|
84
84
|
|
|
85
85
|
#### 使用 CLI 参数(适用于环境变量有问题的客户端)
|
|
86
86
|
|
|
@@ -479,7 +479,7 @@ node build/index.js
|
|
|
479
479
|
对于支持 skill/instruction 加载的 AI 代理(Claude Code、GitHub Copilot、Cursor 等),[`skills/gitlab-mcp/`](./skills/gitlab-mcp/) 中提供了预构建 skill 文件。
|
|
480
480
|
|
|
481
481
|
- **[SKILL.md](./skills/gitlab-mcp/SKILL.md)** — 核心指南,包含工具集概览、关键工作流和参数提示
|
|
482
|
-
- **[reference/](./skills/gitlab-mcp/reference/)** —
|
|
482
|
+
- **[reference/](./skills/gitlab-mcp/reference/)** — 代码审查、合并请求、议题、流水线和漏洞分类的详细工作流文档
|
|
483
483
|
|
|
484
484
|
使用 `skills` CLI 安装:
|
|
485
485
|
|
|
@@ -491,7 +491,7 @@ npx skills add zereight/gitlab-mcp --skill gitlab-mcp-skill
|
|
|
491
491
|
|
|
492
492
|
## 工具 🛠️
|
|
493
493
|
|
|
494
|
-
完整工具列表请参考英文 README 的 [Tools 部分](./README.md#tools-%EF%B8%8F)。当前服务器提供合并请求、议题、流水线、部署、环境、制品、里程碑、Wiki、仓库、发布、用户、事件、work item、webhook
|
|
494
|
+
完整工具列表请参考英文 README 的 [Tools 部分](./README.md#tools-%EF%B8%8F)。当前服务器提供合并请求、议题、流水线、部署、环境、制品、里程碑、Wiki、仓库、发布、用户、事件、work item、webhook、代码搜索、CI/CD 变量、dependency proxy、漏洞分类和 GraphQL 执行相关工具。
|
|
495
495
|
|
|
496
496
|
### Wiki 页面标题与 slug
|
|
497
497
|
|
package/build/downloads/proxy.js
CHANGED
|
@@ -100,8 +100,9 @@ export function registerDownloadProxy(app, deps) {
|
|
|
100
100
|
try {
|
|
101
101
|
apiUrl = deps.resolveTrustedGitLabApiUrl(requestedApiUrl);
|
|
102
102
|
}
|
|
103
|
-
catch {
|
|
104
|
-
|
|
103
|
+
catch (error) {
|
|
104
|
+
const message = error instanceof Error ? error.message : "Invalid X-GitLab-API-URL";
|
|
105
|
+
res.status(400).json({ error: message });
|
|
105
106
|
return;
|
|
106
107
|
}
|
|
107
108
|
}
|
package/build/index.js
CHANGED
|
@@ -90,6 +90,8 @@ import { graphqlQueryContainsWriteOperation, graphqlQueryContainsDeleteOperation
|
|
|
90
90
|
import { resolveNestedWikiUpdateTitle } from "./utils/wiki-title.js";
|
|
91
91
|
import { redactSensitiveGitLabFields } from "./utils/redact-sensitive.js";
|
|
92
92
|
import { checkForNewVersion } from "./utils/version-check.js";
|
|
93
|
+
import { assertGitLabVersionAtLeast } from "./utils/gitlab-version-gate.js";
|
|
94
|
+
import { buildBulkPublishDraftNotesBody, needsGitLab19_2BulkPublish, } from "./utils/bulk-publish-options.js";
|
|
93
95
|
import { cleanMutuallyExclusiveIdUsernameOptions, LIST_MERGE_REQUESTS_ID_USERNAME_PAIRS, sanitizeToolArguments, } from "./utils/tool-args.js";
|
|
94
96
|
import { parseSearchReplaceBlocks, applySearchReplace, applyUnifiedDiff, } from "./utils/patch-helper.js";
|
|
95
97
|
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
|
|
@@ -101,47 +103,11 @@ CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMerg
|
|
|
101
103
|
GitLabDiscussionNoteSchema, // Added
|
|
102
104
|
GitLabDiscussionSchema,
|
|
103
105
|
// Draft Notes Schemas
|
|
104
|
-
GitLabDraftNoteSchema, GitLabForkSchema, GitLabBranchSchema, GitLabProtectedBranchSchema, GitLabGroupSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestPipelineSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabGroupMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabTodoSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchBlobResultSchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitStatusesSchema, ListBranchesSchema, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, GitLabCiVariableSchema, ListProjectVariablesSchema, GetProjectVariableSchema, CreateProjectVariableSchema, UpdateProjectVariableSchema, DeleteProjectVariableSchema, ListGroupVariablesSchema, GetGroupVariableSchema, CreateGroupVariableSchema, UpdateGroupVariableSchema, DeleteGroupVariableSchema, GitLabDependencyProxySchema, GitLabDependencyProxyBlobSchema, GetDependencyProxySettingsSchema, UpdateDependencyProxySettingsSchema, ListDependencyProxyBlobsSchema, PurgeDependencyProxyCacheSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListTodosSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
|
|
106
|
+
GitLabDraftNoteSchema, GitLabForkSchema, GitLabBranchSchema, GitLabProtectedBranchSchema, GitLabGroupSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestPipelineSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabGroupMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabTodoSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchBlobResultSchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitStatusesSchema, ListBranchesSchema, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, GitLabCiVariableSchema, ListProjectVariablesSchema, GetProjectVariableSchema, CreateProjectVariableSchema, UpdateProjectVariableSchema, DeleteProjectVariableSchema, ListGroupVariablesSchema, GetGroupVariableSchema, CreateGroupVariableSchema, UpdateGroupVariableSchema, DeleteGroupVariableSchema, GitLabDependencyProxySchema, GitLabDependencyProxyBlobSchema, GetDependencyProxySettingsSchema, UpdateDependencyProxySettingsSchema, ListDependencyProxyBlobsSchema, PurgeDependencyProxyCacheSchema, ListProjectVulnerabilitiesSchema, GetVulnerabilitySchema, DismissVulnerabilitySchema, ConfirmVulnerabilitySchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListTodosSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
|
|
105
107
|
GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListCiCatalogResourcesSchema, GetCiCatalogResourceSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListGroupMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, GetGroupWikiPageSchema, ListGroupWikiPagesSchema, UpdateGroupWikiPageSchema, MarkdownUploadSchema, MarkdownUploadRemoteSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, MarkAllTodosDoneSchema, MarkTodoDoneSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateProjectSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, ListTagsSchema, GetTagSchema, CreateTagSchema, DeleteTagSchema, GetTagSignatureSchema, GitLabTagSchema, GitLabTagSignatureSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, GetWorkItemSchema, ListWorkItemsSchema, CreateWorkItemSchema, UpdateWorkItemSchema, ConvertWorkItemTypeSchema, ListWorkItemStatusesSchema, ListWorkItemNotesSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, MoveWorkItemSchema, ListCustomFieldDefinitionsSchema, GetTimelineEventsSchema, CreateTimelineEventSchema, ListWebhooksSchema, ListWebhookEventsSchema, GetWebhookEventSchema, HealthCheckSchema, } from "./schemas.js";
|
|
106
108
|
import { randomUUID, timingSafeEqual, } from "node:crypto";
|
|
107
|
-
import {
|
|
108
|
-
const logger =
|
|
109
|
-
level: process.env.LOG_LEVEL || "info",
|
|
110
|
-
// Redact sensitive values that must never appear in logs. Covers both
|
|
111
|
-
// typical auth-context property names and common HTTP header-bag shapes
|
|
112
|
-
// that tool/fetch wrappers may include when logging errors.
|
|
113
|
-
redact: {
|
|
114
|
-
paths: [
|
|
115
|
-
"token",
|
|
116
|
-
"*.token",
|
|
117
|
-
"ctx.token",
|
|
118
|
-
"context.token",
|
|
119
|
-
"authData.token",
|
|
120
|
-
"auth.token",
|
|
121
|
-
"headers.authorization",
|
|
122
|
-
"headers.Authorization",
|
|
123
|
-
'headers["private-token"]',
|
|
124
|
-
'headers["Private-Token"]',
|
|
125
|
-
'headers["job-token"]',
|
|
126
|
-
'headers["JOB-TOKEN"]',
|
|
127
|
-
'headers["mcp-session-id"]',
|
|
128
|
-
'headers["Mcp-Session-Id"]',
|
|
129
|
-
"sessionId",
|
|
130
|
-
"*.sessionId",
|
|
131
|
-
"ctx.sessionId",
|
|
132
|
-
"context.sessionId",
|
|
133
|
-
],
|
|
134
|
-
censor: "[REDACTED]",
|
|
135
|
-
},
|
|
136
|
-
transport: {
|
|
137
|
-
target: "pino-pretty",
|
|
138
|
-
options: {
|
|
139
|
-
colorize: true,
|
|
140
|
-
levelFirst: true,
|
|
141
|
-
destination: 2,
|
|
142
|
-
},
|
|
143
|
-
},
|
|
144
|
-
});
|
|
109
|
+
import { createLogger } from "./utils/logger.js";
|
|
110
|
+
const logger = createLogger();
|
|
145
111
|
/**
|
|
146
112
|
* Read version from package.json
|
|
147
113
|
*/
|
|
@@ -1144,7 +1110,8 @@ function resolveTrustedGitLabApiUrl(value) {
|
|
|
1144
1110
|
}
|
|
1145
1111
|
const allowedApiUrl = GITLAB_ALLOWED_API_URLS_BY_HOST.get(parsed.host);
|
|
1146
1112
|
if (!allowedApiUrl) {
|
|
1147
|
-
throw new Error(`GitLab API URL host is not allowed: ${parsed.host}`
|
|
1113
|
+
throw new Error(`GitLab API URL host is not allowed: ${parsed.host}. ` +
|
|
1114
|
+
"Add the host to GITLAB_ALLOWED_HOSTS or GITLAB_API_URL.");
|
|
1148
1115
|
}
|
|
1149
1116
|
return allowedApiUrl;
|
|
1150
1117
|
}
|
|
@@ -4424,19 +4391,49 @@ async function publishDraftNote(projectId, mergeRequestIid, draftNoteId) {
|
|
|
4424
4391
|
};
|
|
4425
4392
|
}
|
|
4426
4393
|
}
|
|
4394
|
+
async function fetchGitLabInstanceVersion() {
|
|
4395
|
+
try {
|
|
4396
|
+
const response = await fetch(`${getEffectiveApiUrl()}/version`, {
|
|
4397
|
+
...getFetchConfig(),
|
|
4398
|
+
});
|
|
4399
|
+
if (!response.ok)
|
|
4400
|
+
return null;
|
|
4401
|
+
const data = await response.json();
|
|
4402
|
+
if (typeof data === "object" &&
|
|
4403
|
+
data !== null &&
|
|
4404
|
+
"version" in data &&
|
|
4405
|
+
typeof data.version === "string") {
|
|
4406
|
+
return data.version;
|
|
4407
|
+
}
|
|
4408
|
+
return null;
|
|
4409
|
+
}
|
|
4410
|
+
catch {
|
|
4411
|
+
return null;
|
|
4412
|
+
}
|
|
4413
|
+
}
|
|
4427
4414
|
/**
|
|
4428
4415
|
* Publish all draft notes for a merge request
|
|
4429
4416
|
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
4430
4417
|
* @param {number|string} mergeRequestIid - The internal ID of the merge request
|
|
4418
|
+
* @param {BulkPublishDraftNotesBody} options - Optional GitLab 19.2+ bulk_publish body fields
|
|
4431
4419
|
* @returns {Promise<GitLabDiscussionNote[]>} Array of published notes
|
|
4432
4420
|
*/
|
|
4433
|
-
async function bulkPublishDraftNotes(projectId, mergeRequestIid) {
|
|
4421
|
+
async function bulkPublishDraftNotes(projectId, mergeRequestIid, options = {}) {
|
|
4434
4422
|
projectId = decodeURIComponent(projectId);
|
|
4435
4423
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${encodeGitLabPathSegment(mergeRequestIid)}/draft_notes/bulk_publish`);
|
|
4424
|
+
const body = buildBulkPublishDraftNotesBody(options);
|
|
4425
|
+
if (needsGitLab19_2BulkPublish(body)) {
|
|
4426
|
+
await assertGitLabVersionAtLeast({
|
|
4427
|
+
major: 19,
|
|
4428
|
+
minor: 2,
|
|
4429
|
+
feature: "reviewer_state, note, and internal on bulk_publish_draft_notes",
|
|
4430
|
+
retryHint: "Omit reviewer_state, note, and internal, then retry.",
|
|
4431
|
+
}, fetchGitLabInstanceVersion);
|
|
4432
|
+
}
|
|
4436
4433
|
const response = await fetch(url.toString(), {
|
|
4437
4434
|
...getFetchConfig(),
|
|
4438
4435
|
method: "POST", // Changed from PUT to POST
|
|
4439
|
-
body: JSON.stringify(
|
|
4436
|
+
body: JSON.stringify(body),
|
|
4440
4437
|
});
|
|
4441
4438
|
if (!response.ok) {
|
|
4442
4439
|
const errorText = await response.text();
|
|
@@ -6421,6 +6418,262 @@ async function purgeDependencyProxyCache(groupId) {
|
|
|
6421
6418
|
const response = await fetch(url.toString(), { ...getFetchConfig(), method: "DELETE" });
|
|
6422
6419
|
await handleGitLabError(response);
|
|
6423
6420
|
}
|
|
6421
|
+
// --- Vulnerability functions (GraphQL) ---
|
|
6422
|
+
//
|
|
6423
|
+
// The REST Vulnerabilities API is deprecated and silently ignores filter
|
|
6424
|
+
// params on list and reason/comment on dismiss. GitLab recommends the
|
|
6425
|
+
// GraphQL API for vulnerability management, which supports all of these.
|
|
6426
|
+
/** Convert a numeric vulnerability ID (or an existing Vulnerability GID) to a GraphQL global ID. */
|
|
6427
|
+
function toVulnerabilityGid(vulnerabilityId) {
|
|
6428
|
+
if (/^\d+$/.test(vulnerabilityId)) {
|
|
6429
|
+
return `gid://gitlab/Vulnerability/${vulnerabilityId}`;
|
|
6430
|
+
}
|
|
6431
|
+
if (/^gid:\/\/gitlab\/Vulnerability\/\d+$/.test(vulnerabilityId)) {
|
|
6432
|
+
return vulnerabilityId;
|
|
6433
|
+
}
|
|
6434
|
+
throw new Error(`Invalid vulnerability ID "${vulnerabilityId}": expected a numeric ID or a gid://gitlab/Vulnerability/<id> global ID`);
|
|
6435
|
+
}
|
|
6436
|
+
/**
|
|
6437
|
+
* Resolve a project ID or path to the full namespace path required by
|
|
6438
|
+
* GraphQL's `project(fullPath:)` query. Numeric IDs are resolved via a
|
|
6439
|
+
* REST lookup; paths are returned as-is (decoded).
|
|
6440
|
+
*/
|
|
6441
|
+
async function resolveProjectFullPath(projectId) {
|
|
6442
|
+
const decoded = decodeURIComponent(getEffectiveProjectId(decodeURIComponent(projectId)));
|
|
6443
|
+
if (!/^\d+$/.test(decoded)) {
|
|
6444
|
+
return decoded;
|
|
6445
|
+
}
|
|
6446
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(decoded)}`);
|
|
6447
|
+
const response = await fetch(url.toString(), { ...getFetchConfig() });
|
|
6448
|
+
await handleGitLabError(response);
|
|
6449
|
+
const project = (await response.json());
|
|
6450
|
+
if (!project.path_with_namespace) {
|
|
6451
|
+
throw new Error(`Could not resolve full path for project ${decoded}`);
|
|
6452
|
+
}
|
|
6453
|
+
return project.path_with_namespace;
|
|
6454
|
+
}
|
|
6455
|
+
/**
|
|
6456
|
+
* Enforce GITLAB_ALLOWED_PROJECT_IDS for vulnerability tools. Vulnerability
|
|
6457
|
+
* GIDs are globally unique rather than project-scoped, so the boundary must
|
|
6458
|
+
* be checked against the project the vulnerability actually belongs to —
|
|
6459
|
+
* mirroring what getEffectiveProjectId does for project-scoped REST calls.
|
|
6460
|
+
* Allowlist entries may be numeric IDs or full namespace paths.
|
|
6461
|
+
*/
|
|
6462
|
+
function assertVulnerabilityProjectAllowed(vulnerabilityId, project) {
|
|
6463
|
+
if (GITLAB_ALLOWED_PROJECT_IDS.length === 0) {
|
|
6464
|
+
return;
|
|
6465
|
+
}
|
|
6466
|
+
const fullPath = project?.fullPath ?? undefined;
|
|
6467
|
+
const numericId = project?.id?.match(/^gid:\/\/gitlab\/Project\/(\d+)$/)?.[1];
|
|
6468
|
+
const allowed = (fullPath !== undefined && GITLAB_ALLOWED_PROJECT_IDS.includes(fullPath)) ||
|
|
6469
|
+
(numericId !== undefined && GITLAB_ALLOWED_PROJECT_IDS.includes(numericId));
|
|
6470
|
+
if (!allowed) {
|
|
6471
|
+
throw new Error(`Access denied: Vulnerability ${vulnerabilityId} belongs to project ${fullPath ?? numericId ?? "unknown"}, which is not in the allowed project list: ${GITLAB_ALLOWED_PROJECT_IDS.join(", ")}`);
|
|
6472
|
+
}
|
|
6473
|
+
}
|
|
6474
|
+
/**
|
|
6475
|
+
* Pre-flight allowlist check for vulnerability mutations: resolves the
|
|
6476
|
+
* vulnerability's project and verifies it against GITLAB_ALLOWED_PROJECT_IDS
|
|
6477
|
+
* before any write is issued. No-op (no extra request) when the allowlist
|
|
6478
|
+
* is not configured.
|
|
6479
|
+
*/
|
|
6480
|
+
async function ensureVulnerabilityProjectAllowed(vulnerabilityId) {
|
|
6481
|
+
if (GITLAB_ALLOWED_PROJECT_IDS.length === 0) {
|
|
6482
|
+
return;
|
|
6483
|
+
}
|
|
6484
|
+
const data = await executeGraphQL(`query getVulnerabilityProject($id: VulnerabilityID!) {
|
|
6485
|
+
vulnerability(id: $id) {
|
|
6486
|
+
project {
|
|
6487
|
+
id
|
|
6488
|
+
fullPath
|
|
6489
|
+
}
|
|
6490
|
+
}
|
|
6491
|
+
}`, { id: toVulnerabilityGid(vulnerabilityId) });
|
|
6492
|
+
if (!data.vulnerability) {
|
|
6493
|
+
throw new Error(`Vulnerability not found: ${vulnerabilityId}`);
|
|
6494
|
+
}
|
|
6495
|
+
assertVulnerabilityProjectAllowed(vulnerabilityId, data.vulnerability.project);
|
|
6496
|
+
}
|
|
6497
|
+
/** Shared GraphQL selection set for vulnerability objects. */
|
|
6498
|
+
const VULNERABILITY_FIELDS = `
|
|
6499
|
+
id
|
|
6500
|
+
title
|
|
6501
|
+
description
|
|
6502
|
+
state
|
|
6503
|
+
severity
|
|
6504
|
+
reportType
|
|
6505
|
+
detectedAt
|
|
6506
|
+
confirmedAt
|
|
6507
|
+
resolvedAt
|
|
6508
|
+
dismissedAt
|
|
6509
|
+
dismissalReason
|
|
6510
|
+
webUrl
|
|
6511
|
+
scanner {
|
|
6512
|
+
name
|
|
6513
|
+
externalId
|
|
6514
|
+
vendor
|
|
6515
|
+
}
|
|
6516
|
+
identifiers {
|
|
6517
|
+
externalType
|
|
6518
|
+
externalId
|
|
6519
|
+
name
|
|
6520
|
+
url
|
|
6521
|
+
}
|
|
6522
|
+
links {
|
|
6523
|
+
name
|
|
6524
|
+
url
|
|
6525
|
+
}
|
|
6526
|
+
location {
|
|
6527
|
+
... on VulnerabilityLocationSast {
|
|
6528
|
+
file
|
|
6529
|
+
startLine
|
|
6530
|
+
endLine
|
|
6531
|
+
}
|
|
6532
|
+
... on VulnerabilityLocationSecretDetection {
|
|
6533
|
+
file
|
|
6534
|
+
startLine
|
|
6535
|
+
endLine
|
|
6536
|
+
}
|
|
6537
|
+
... on VulnerabilityLocationDependencyScanning {
|
|
6538
|
+
file
|
|
6539
|
+
dependency {
|
|
6540
|
+
package {
|
|
6541
|
+
name
|
|
6542
|
+
}
|
|
6543
|
+
version
|
|
6544
|
+
}
|
|
6545
|
+
}
|
|
6546
|
+
... on VulnerabilityLocationContainerScanning {
|
|
6547
|
+
image
|
|
6548
|
+
operatingSystem
|
|
6549
|
+
dependency {
|
|
6550
|
+
package {
|
|
6551
|
+
name
|
|
6552
|
+
}
|
|
6553
|
+
version
|
|
6554
|
+
}
|
|
6555
|
+
}
|
|
6556
|
+
... on VulnerabilityLocationDast {
|
|
6557
|
+
path
|
|
6558
|
+
hostname
|
|
6559
|
+
}
|
|
6560
|
+
}
|
|
6561
|
+
`;
|
|
6562
|
+
async function listProjectVulnerabilities(projectId, options = {}) {
|
|
6563
|
+
const fullPath = await resolveProjectFullPath(projectId);
|
|
6564
|
+
const variables = {
|
|
6565
|
+
fullPath,
|
|
6566
|
+
first: Math.min(options.first ?? 20, 100),
|
|
6567
|
+
};
|
|
6568
|
+
if (options.state)
|
|
6569
|
+
variables.state = [options.state.toUpperCase()];
|
|
6570
|
+
if (options.severity)
|
|
6571
|
+
variables.severity = [options.severity.toUpperCase()];
|
|
6572
|
+
if (options.report_type)
|
|
6573
|
+
variables.reportType = [options.report_type.toUpperCase()];
|
|
6574
|
+
if (options.after)
|
|
6575
|
+
variables.after = options.after;
|
|
6576
|
+
const data = await executeGraphQL(`query listProjectVulnerabilities(
|
|
6577
|
+
$fullPath: ID!
|
|
6578
|
+
$state: [VulnerabilityState!]
|
|
6579
|
+
$severity: [VulnerabilitySeverity!]
|
|
6580
|
+
$reportType: [VulnerabilityReportType!]
|
|
6581
|
+
$first: Int
|
|
6582
|
+
$after: String
|
|
6583
|
+
) {
|
|
6584
|
+
project(fullPath: $fullPath) {
|
|
6585
|
+
vulnerabilities(
|
|
6586
|
+
state: $state
|
|
6587
|
+
severity: $severity
|
|
6588
|
+
reportType: $reportType
|
|
6589
|
+
first: $first
|
|
6590
|
+
after: $after
|
|
6591
|
+
) {
|
|
6592
|
+
nodes {
|
|
6593
|
+
${VULNERABILITY_FIELDS}
|
|
6594
|
+
}
|
|
6595
|
+
pageInfo {
|
|
6596
|
+
endCursor
|
|
6597
|
+
hasNextPage
|
|
6598
|
+
}
|
|
6599
|
+
}
|
|
6600
|
+
}
|
|
6601
|
+
}`, variables);
|
|
6602
|
+
if (!data.project) {
|
|
6603
|
+
throw new Error(`Project not found or not accessible: ${fullPath}`);
|
|
6604
|
+
}
|
|
6605
|
+
return {
|
|
6606
|
+
vulnerabilities: data.project.vulnerabilities.nodes,
|
|
6607
|
+
pageInfo: data.project.vulnerabilities.pageInfo,
|
|
6608
|
+
};
|
|
6609
|
+
}
|
|
6610
|
+
async function getVulnerability(vulnerabilityId) {
|
|
6611
|
+
const data = await executeGraphQL(`query getVulnerability($id: VulnerabilityID!) {
|
|
6612
|
+
vulnerability(id: $id) {
|
|
6613
|
+
${VULNERABILITY_FIELDS}
|
|
6614
|
+
project {
|
|
6615
|
+
id
|
|
6616
|
+
name
|
|
6617
|
+
fullPath
|
|
6618
|
+
}
|
|
6619
|
+
}
|
|
6620
|
+
}`, { id: toVulnerabilityGid(vulnerabilityId) });
|
|
6621
|
+
if (!data.vulnerability) {
|
|
6622
|
+
throw new Error(`Vulnerability not found: ${vulnerabilityId}`);
|
|
6623
|
+
}
|
|
6624
|
+
assertVulnerabilityProjectAllowed(vulnerabilityId, data.vulnerability.project);
|
|
6625
|
+
return data.vulnerability;
|
|
6626
|
+
}
|
|
6627
|
+
async function dismissVulnerability(vulnerabilityId, reason, comment) {
|
|
6628
|
+
await ensureVulnerabilityProjectAllowed(vulnerabilityId);
|
|
6629
|
+
const input = {
|
|
6630
|
+
id: toVulnerabilityGid(vulnerabilityId),
|
|
6631
|
+
dismissalReason: reason.toUpperCase(),
|
|
6632
|
+
};
|
|
6633
|
+
if (comment)
|
|
6634
|
+
input.comment = comment;
|
|
6635
|
+
const data = await executeGraphQL(`mutation dismissVulnerability($input: VulnerabilityDismissInput!) {
|
|
6636
|
+
vulnerabilityDismiss(input: $input) {
|
|
6637
|
+
vulnerability {
|
|
6638
|
+
id
|
|
6639
|
+
state
|
|
6640
|
+
dismissedAt
|
|
6641
|
+
dismissalReason
|
|
6642
|
+
}
|
|
6643
|
+
errors
|
|
6644
|
+
}
|
|
6645
|
+
}`, { input });
|
|
6646
|
+
if (data.vulnerabilityDismiss.errors?.length) {
|
|
6647
|
+
throw new Error(`Failed to dismiss vulnerability: ${data.vulnerabilityDismiss.errors.join(", ")}`);
|
|
6648
|
+
}
|
|
6649
|
+
if (!data.vulnerabilityDismiss.vulnerability) {
|
|
6650
|
+
throw new Error(`Vulnerability not returned after dismissal (id: ${vulnerabilityId})`);
|
|
6651
|
+
}
|
|
6652
|
+
return data.vulnerabilityDismiss.vulnerability;
|
|
6653
|
+
}
|
|
6654
|
+
async function confirmVulnerability(vulnerabilityId, comment) {
|
|
6655
|
+
await ensureVulnerabilityProjectAllowed(vulnerabilityId);
|
|
6656
|
+
const input = { id: toVulnerabilityGid(vulnerabilityId) };
|
|
6657
|
+
if (comment)
|
|
6658
|
+
input.comment = comment;
|
|
6659
|
+
const data = await executeGraphQL(`mutation confirmVulnerability($input: VulnerabilityConfirmInput!) {
|
|
6660
|
+
vulnerabilityConfirm(input: $input) {
|
|
6661
|
+
vulnerability {
|
|
6662
|
+
id
|
|
6663
|
+
state
|
|
6664
|
+
confirmedAt
|
|
6665
|
+
}
|
|
6666
|
+
errors
|
|
6667
|
+
}
|
|
6668
|
+
}`, { input });
|
|
6669
|
+
if (data.vulnerabilityConfirm.errors?.length) {
|
|
6670
|
+
throw new Error(`Failed to confirm vulnerability: ${data.vulnerabilityConfirm.errors.join(", ")}`);
|
|
6671
|
+
}
|
|
6672
|
+
if (!data.vulnerabilityConfirm.vulnerability) {
|
|
6673
|
+
throw new Error(`Vulnerability not returned after confirmation (id: ${vulnerabilityId})`);
|
|
6674
|
+
}
|
|
6675
|
+
return data.vulnerabilityConfirm.vulnerability;
|
|
6676
|
+
}
|
|
6424
6677
|
/**
|
|
6425
6678
|
* Upload a file to a GitLab project for use in markdown content.
|
|
6426
6679
|
*
|
|
@@ -7612,8 +7865,8 @@ async function handleToolCall(params) {
|
|
|
7612
7865
|
}
|
|
7613
7866
|
case "bulk_publish_draft_notes": {
|
|
7614
7867
|
const args = BulkPublishDraftNotesSchema.parse(params.arguments);
|
|
7615
|
-
const { project_id, merge_request_iid } = args;
|
|
7616
|
-
const publishedNotes = await bulkPublishDraftNotes(project_id, merge_request_iid);
|
|
7868
|
+
const { project_id, merge_request_iid, ...options } = args;
|
|
7869
|
+
const publishedNotes = await bulkPublishDraftNotes(project_id, merge_request_iid, options);
|
|
7617
7870
|
return {
|
|
7618
7871
|
content: [{ type: "text", text: JSON.stringify(publishedNotes) }],
|
|
7619
7872
|
};
|
|
@@ -8837,6 +9090,28 @@ async function handleToolCall(params) {
|
|
|
8837
9090
|
],
|
|
8838
9091
|
};
|
|
8839
9092
|
}
|
|
9093
|
+
// --- Vulnerability tools ---
|
|
9094
|
+
case "list_project_vulnerabilities": {
|
|
9095
|
+
const args = ListProjectVulnerabilitiesSchema.parse(params.arguments);
|
|
9096
|
+
const { project_id, ...options } = args;
|
|
9097
|
+
const result = await listProjectVulnerabilities(project_id, options);
|
|
9098
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
9099
|
+
}
|
|
9100
|
+
case "get_vulnerability": {
|
|
9101
|
+
const args = GetVulnerabilitySchema.parse(params.arguments);
|
|
9102
|
+
const result = await getVulnerability(args.vulnerability_id);
|
|
9103
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
9104
|
+
}
|
|
9105
|
+
case "dismiss_vulnerability": {
|
|
9106
|
+
const args = DismissVulnerabilitySchema.parse(params.arguments);
|
|
9107
|
+
const result = await dismissVulnerability(args.vulnerability_id, args.reason, args.comment);
|
|
9108
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
9109
|
+
}
|
|
9110
|
+
case "confirm_vulnerability": {
|
|
9111
|
+
const args = ConfirmVulnerabilitySchema.parse(params.arguments);
|
|
9112
|
+
const result = await confirmVulnerability(args.vulnerability_id, args.comment);
|
|
9113
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
9114
|
+
}
|
|
8840
9115
|
case "upload_markdown": {
|
|
8841
9116
|
if (IS_REMOTE) {
|
|
8842
9117
|
const args = MarkdownUploadRemoteSchema.parse(params.arguments);
|
|
@@ -9464,8 +9739,9 @@ async function startStreamableHTTPServer() {
|
|
|
9464
9739
|
try {
|
|
9465
9740
|
apiUrl = resolveTrustedGitLabApiUrl(dynamicApiUrl);
|
|
9466
9741
|
}
|
|
9467
|
-
catch {
|
|
9468
|
-
|
|
9742
|
+
catch (error) {
|
|
9743
|
+
const reason = error instanceof Error ? error.message : "Invalid X-GitLab-API-URL";
|
|
9744
|
+
logger.warn(`Invalid X-GitLab-API-URL provided: ${dynamicApiUrl}. ${reason}`);
|
|
9469
9745
|
return null; // Reject if URL is malformed or not allowed
|
|
9470
9746
|
}
|
|
9471
9747
|
}
|
package/build/oauth-proxy.js
CHANGED
|
@@ -47,11 +47,11 @@
|
|
|
47
47
|
import { InvalidTokenError, ServerError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
|
48
48
|
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
49
49
|
import { randomUUID, randomBytes, createHash } from "node:crypto";
|
|
50
|
-
import { pino } from "pino";
|
|
51
50
|
import { looksLikeStatelessClientId, mintClientId, openClientId, } from "./stateless/client-id.js";
|
|
52
51
|
import { looksLikeStatelessState, mintPendingAuthState, openPendingAuthState, } from "./stateless/pending-auth.js";
|
|
53
52
|
import { looksLikeStatelessStoredTokensCode, mintStoredTokensCode, openStoredTokensCode, } from "./stateless/stored-tokens.js";
|
|
54
|
-
|
|
53
|
+
import { createLogger } from "./utils/logger.js";
|
|
54
|
+
const logger = createLogger("gitlab-mcp-oauth-proxy");
|
|
55
55
|
// ---------------------------------------------------------------------------
|
|
56
56
|
// GitLab OAuth Server Provider
|
|
57
57
|
// ---------------------------------------------------------------------------
|
package/build/oauth.js
CHANGED
|
@@ -9,11 +9,8 @@ import { execFile } from "child_process";
|
|
|
9
9
|
import { promisify } from "util";
|
|
10
10
|
import open from "open";
|
|
11
11
|
import pkceChallenge from "pkce-challenge";
|
|
12
|
-
import {
|
|
13
|
-
const logger =
|
|
14
|
-
name: "gitlab-mcp-oauth",
|
|
15
|
-
level: process.env.LOG_LEVEL || "info",
|
|
16
|
-
}, pino.destination(2));
|
|
12
|
+
import { createLogger } from "./utils/logger.js";
|
|
13
|
+
const logger = createLogger("gitlab-mcp-oauth");
|
|
17
14
|
const execFileAsync = promisify(execFile);
|
|
18
15
|
function escapeHtml(str) {
|
|
19
16
|
const map = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|