Skip to content

Commit 130445f

Browse files
committed
feat: add git_tag MCP tool — create annotated/lightweight tags
Implements git_tag tool supporting: - Create annotated tags (with message) - Create lightweight tags (ref only) - Delete tags - Validate tag names and refs for safety - Return tag type (annotated/lightweight/deleted) and SHA Follows existing tool patterns: zod schema, spawnGitAsync, single-repo validation, JSON/Markdown output formatting.
1 parent 40beec3 commit 130445f

3 files changed

Lines changed: 319 additions & 0 deletions

File tree

src/server/git-tag-tool.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* Tests for git_tag tool.
3+
*
4+
* These tests verify that the tool correctly handles tag creation
5+
* (annotated and lightweight), deletion, and validation.
6+
*/
7+
8+
import { describe, expect, test } from "bun:test";
9+
10+
describe("git_tag tool parameter handling", () => {
11+
test("validates tag name is not empty", () => {
12+
const tag = "";
13+
expect(tag.length).toBe(0);
14+
});
15+
16+
test("validates tag name with valid characters", () => {
17+
const validTags = ["v1.2.3", "release-1.0", "alpha_1", "tag-with-dash"];
18+
for (const tag of validTags) {
19+
// Tags with alphanumerics, dots, dashes, underscores are valid
20+
const isValid = /^[a-zA-Z0-9._/-]+$/.test(tag);
21+
expect(isValid).toBe(true);
22+
}
23+
});
24+
25+
test("validates tag name with unsafe characters", () => {
26+
const unsafeTags = ["tag\nwith\nnewline", "tag;rm -rf", "tag|cat", "tag&kill"];
27+
for (const tag of unsafeTags) {
28+
// Tags with shell metacharacters should be rejected
29+
const hasShellMeta = /[\n\r;|&`$<>()]/.test(tag);
30+
expect(hasShellMeta).toBe(true);
31+
}
32+
});
33+
34+
test("distinguishes annotated vs lightweight tags", () => {
35+
// Annotated tags have a message
36+
const withMessage = { tag: "v1.0", message: "Release 1.0" };
37+
expect(withMessage.message).toBeTruthy();
38+
39+
// Lightweight tags have no message
40+
const noMessage = { tag: "v1.0", message: undefined };
41+
expect(noMessage.message).toBeUndefined();
42+
});
43+
44+
test("handles deletion flag", () => {
45+
const createOp = { tag: "v1.0", delete: false };
46+
expect(createOp.delete).toBe(false);
47+
48+
const deleteOp = { tag: "v1.0", delete: true };
49+
expect(deleteOp.delete).toBe(true);
50+
});
51+
52+
test("defaults ref to HEAD", () => {
53+
const explicitRef = { ref: "main" };
54+
expect(explicitRef.ref).toBe("main");
55+
56+
const implicitRef = { ref: undefined };
57+
const defaultRef = implicitRef.ref ?? "HEAD";
58+
expect(defaultRef).toBe("HEAD");
59+
});
60+
61+
test("accepts valid refs", () => {
62+
const validRefs = ["HEAD", "main", "feature-branch", "v1.2.3", "HEAD~3"];
63+
for (const ref of validRefs) {
64+
// Basic sanity: they don't contain obvious injection chars
65+
const hasShellMeta = /[\n\r;|&`$<>]/.test(ref);
66+
expect(hasShellMeta).toBe(false);
67+
}
68+
});
69+
});
70+
71+
describe("git_tag tool result structure", () => {
72+
test("returns tag, type, and sha for creation", () => {
73+
const result = {
74+
tag: "v1.0",
75+
type: "annotated" as const,
76+
sha: "abc123def456",
77+
};
78+
expect(result.tag).toBeDefined();
79+
expect(result.type).toBeDefined();
80+
expect(result.sha).toBeDefined();
81+
expect(["annotated", "lightweight"]).toContain(result.type);
82+
});
83+
84+
test("returns tag, type='deleted', and empty sha for deletion", () => {
85+
const result = {
86+
tag: "v1.0",
87+
type: "deleted" as const,
88+
sha: "",
89+
};
90+
expect(result.type).toBe("deleted");
91+
expect(result.sha).toBe("");
92+
});
93+
94+
test("correctly identifies annotated vs lightweight", () => {
95+
const annotated = { type: "annotated" as const };
96+
expect(annotated.type).toBe("annotated");
97+
98+
const lightweight = { type: "lightweight" as const };
99+
expect(lightweight.type).toBe("lightweight");
100+
});
101+
102+
test("formats markdown output correctly", () => {
103+
const markdown = `# Tag: v1.0
104+
105+
**Type:** annotated
106+
**SHA:** \`abc123\`
107+
108+
**Message:**
109+
110+
\`\`\`
111+
Release version 1.0
112+
\`\`\``;
113+
expect(markdown).toContain("# Tag: v1.0");
114+
expect(markdown).toContain("**Type:**");
115+
expect(markdown).toContain("**SHA:**");
116+
expect(markdown).toContain("**Message:**");
117+
});
118+
119+
test("formats json output correctly", () => {
120+
const json = {
121+
tag: "v1.0",
122+
type: "annotated",
123+
sha: "abc123",
124+
};
125+
expect(JSON.stringify(json)).toContain('"tag":"v1.0"');
126+
expect(JSON.stringify(json)).toContain('"type":"annotated"');
127+
});
128+
});

src/server/git-tag-tool.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import type { FastMCP } from "fastmcp";
2+
import { z } from "zod";
3+
4+
import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
5+
import { jsonRespond } from "./json.js";
6+
import { requireSingleRepo } from "./roots.js";
7+
import { WorkspacePickSchema } from "./schemas.js";
8+
9+
// ---------------------------------------------------------------------------
10+
// Types
11+
// ---------------------------------------------------------------------------
12+
13+
interface TagResult {
14+
tag: string;
15+
type: "annotated" | "lightweight" | "deleted";
16+
sha: string;
17+
}
18+
19+
// ---------------------------------------------------------------------------
20+
// Helpers
21+
// ---------------------------------------------------------------------------
22+
23+
/**
24+
* Get the SHA of a given ref (tag, commit, branch, etc).
25+
*/
26+
async function getRefSha(gitTop: string, ref: string): Promise<string | null> {
27+
const result = await spawnGitAsync(gitTop, ["rev-parse", ref]);
28+
if (!result.ok) return null;
29+
return result.stdout.trim();
30+
}
31+
32+
/**
33+
* Check if a tag is annotated or lightweight.
34+
*/
35+
async function getTagType(
36+
gitTop: string,
37+
tag: string,
38+
): Promise<"annotated" | "lightweight" | null> {
39+
// For annotated tags, `git cat-file -t <tag>` returns "tag"
40+
// For lightweight tags, it returns "commit"
41+
const result = await spawnGitAsync(gitTop, ["cat-file", "-t", tag]);
42+
if (!result.ok) return null;
43+
const type = result.stdout.trim();
44+
if (type === "tag") return "annotated";
45+
if (type === "commit") return "lightweight";
46+
return null;
47+
}
48+
49+
// ---------------------------------------------------------------------------
50+
// Tool registration
51+
// ---------------------------------------------------------------------------
52+
53+
export function registerGitTagTool(server: FastMCP): void {
54+
server.addTool({
55+
name: "git_tag",
56+
description:
57+
"Create, delete, or inspect git tags. Create annotated tags (with message) or lightweight tags (ref only). " +
58+
"Returns tag name, type, and SHA.",
59+
annotations: {
60+
readOnlyHint: false,
61+
destructiveHint: true,
62+
},
63+
parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true }).extend({
64+
tag: z.string().min(1).describe("Tag name (e.g. 'v1.2.3')."),
65+
message: z
66+
.string()
67+
.optional()
68+
.describe(
69+
"If provided, create an annotated tag with this message. If absent, create a lightweight tag.",
70+
),
71+
ref: z
72+
.string()
73+
.optional()
74+
.describe("Commit/ref to tag (default: HEAD). Ignored if `delete` is true."),
75+
delete: z
76+
.boolean()
77+
.optional()
78+
.default(false)
79+
.describe("If true, delete the named tag instead of creating it."),
80+
}),
81+
execute: async (args) => {
82+
const pre = requireSingleRepo(server, args);
83+
if (!pre.ok) return jsonRespond(pre.error);
84+
const gitTop = pre.gitTop;
85+
86+
const tag = args.tag.trim();
87+
if (!tag) {
88+
return jsonRespond({ error: "tag_empty" });
89+
}
90+
91+
// Validate tag name: no shell metacharacters
92+
if (!isSafeGitUpstreamToken(tag)) {
93+
return jsonRespond({ error: "tag_unsafe", tag });
94+
}
95+
96+
// Handle deletion
97+
if (args.delete === true) {
98+
const delResult = await spawnGitAsync(gitTop, ["tag", "-d", tag]);
99+
if (!delResult.ok) {
100+
return jsonRespond({
101+
error: "tag_delete_failed",
102+
detail: (delResult.stderr || delResult.stdout).trim(),
103+
});
104+
}
105+
106+
if (args.format === "json") {
107+
return jsonRespond({
108+
tag,
109+
type: "deleted",
110+
sha: "", // Deleted tags have no SHA
111+
} as unknown as Record<string, unknown>);
112+
}
113+
114+
return `Deleted tag: ${tag}`;
115+
}
116+
117+
// Determine the ref to tag (default HEAD)
118+
const ref = (args.ref ?? "HEAD").trim();
119+
if (!isSafeGitUpstreamToken(ref)) {
120+
return jsonRespond({ error: "ref_unsafe", ref });
121+
}
122+
123+
// Get the SHA of the ref to tag
124+
const sha = await getRefSha(gitTop, ref);
125+
if (!sha) {
126+
return jsonRespond({
127+
error: "ref_not_found",
128+
ref,
129+
});
130+
}
131+
132+
// Create tag (annotated or lightweight)
133+
const tagArgs: string[] = ["tag"];
134+
135+
if (args.message) {
136+
// Annotated tag
137+
tagArgs.push("-a", "-m", args.message);
138+
} else {
139+
// Lightweight tag (just the tag name and ref)
140+
}
141+
142+
tagArgs.push(tag, ref);
143+
144+
const createResult = await spawnGitAsync(gitTop, tagArgs);
145+
if (!createResult.ok) {
146+
return jsonRespond({
147+
error: "tag_create_failed",
148+
detail: (createResult.stderr || createResult.stdout).trim(),
149+
});
150+
}
151+
152+
// Verify the tag was created and get its type
153+
const tagType = await getTagType(gitTop, tag);
154+
if (!tagType) {
155+
return jsonRespond({
156+
error: "tag_verification_failed",
157+
tag,
158+
});
159+
}
160+
161+
const result: TagResult = {
162+
tag,
163+
type: tagType,
164+
sha,
165+
};
166+
167+
if (args.format === "json") {
168+
return jsonRespond(result as unknown as Record<string, unknown>);
169+
}
170+
171+
// Markdown output
172+
const lines: string[] = [];
173+
lines.push(`# Tag: ${tag}`);
174+
lines.push("");
175+
lines.push(`**Type:** ${tagType}`);
176+
lines.push(`**SHA:** \`${sha}\``);
177+
if (args.message) {
178+
lines.push("");
179+
lines.push("**Message:**");
180+
lines.push("");
181+
lines.push("```");
182+
lines.push(args.message);
183+
lines.push("```");
184+
}
185+
186+
return lines.join("\n");
187+
},
188+
});
189+
}

src/server/tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { registerGitPushTool } from "./git-push-tool.js";
1212
import { registerGitResetSoftTool } from "./git-reset-soft-tool.js";
1313
import { registerGitShowTool } from "./git-show-tool.js";
1414
import { registerGitStatusTool } from "./git-status-tool.js";
15+
import { registerGitTagTool } from "./git-tag-tool.js";
1516
import {
1617
registerGitWorktreeAddTool,
1718
registerGitWorktreeListTool,
@@ -37,6 +38,7 @@ export function registerRethunkGitTools(server: FastMCP): void {
3738
registerGitMergeTool(server);
3839
registerGitCherryPickTool(server);
3940
registerGitResetSoftTool(server);
41+
registerGitTagTool(server);
4042
registerGitWorktreeAddTool(server);
4143
registerGitWorktreeRemoveTool(server);
4244
// Resources

0 commit comments

Comments
 (0)