safe-push 0.3.0 → 0.5.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/.claude/settings.local.json +12 -0
- package/.mcp.json +8 -0
- package/bun.lock +233 -0
- package/config.schema.json +47 -0
- package/dist/index.js +28862 -4751
- package/dist/mcp.js +29184 -0
- package/package.json +11 -3
- package/scripts/generate-schema.ts +13 -0
- package/src/checker.ts +79 -60
- package/src/commands/check.ts +17 -10
- package/src/commands/mcp.ts +383 -0
- package/src/commands/push.ts +35 -22
- package/src/config.ts +9 -1
- package/src/git.ts +136 -100
- package/src/index.ts +40 -2
- package/src/telemetry.ts +95 -0
- package/src/types.ts +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safe-push",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Git push safety checker - blocks pushes to forbidden areas",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,9 +9,16 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"build": "bun build ./src/index.ts --outdir ./dist --target bun",
|
|
11
11
|
"typecheck": "tsc --noEmit",
|
|
12
|
-
"dev": "bun run ./src/index.ts"
|
|
12
|
+
"dev": "bun run ./src/index.ts",
|
|
13
|
+
"generate-schema": "bun run ./scripts/generate-schema.ts"
|
|
13
14
|
},
|
|
14
15
|
"dependencies": {
|
|
16
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
17
|
+
"@opentelemetry/api": "^1.9.0",
|
|
18
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.212.0",
|
|
19
|
+
"@opentelemetry/resources": "^2.5.1",
|
|
20
|
+
"@opentelemetry/sdk-trace-base": "^2.5.1",
|
|
21
|
+
"@opentelemetry/semantic-conventions": "^1.39.0",
|
|
15
22
|
"commander": "^12.1.0",
|
|
16
23
|
"jsonc-parser": "^3.3.1",
|
|
17
24
|
"zod": "^3.23.8"
|
|
@@ -19,6 +26,7 @@
|
|
|
19
26
|
"devDependencies": {
|
|
20
27
|
"@types/bun": "latest",
|
|
21
28
|
"@types/node": "^22.10.0",
|
|
22
|
-
"typescript": "^5.7.2"
|
|
29
|
+
"typescript": "^5.7.2",
|
|
30
|
+
"zod-to-json-schema": "^3.25.1"
|
|
23
31
|
}
|
|
24
32
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
4
|
+
import { ConfigSchema } from "../src/types";
|
|
5
|
+
|
|
6
|
+
const jsonSchema = zodToJsonSchema(ConfigSchema, {
|
|
7
|
+
name: "SafePushConfig",
|
|
8
|
+
$refStrategy: "none",
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const outPath = path.join(import.meta.dirname, "..", "config.schema.json");
|
|
12
|
+
fs.writeFileSync(outPath, JSON.stringify(jsonSchema, null, 2) + "\n", "utf-8");
|
|
13
|
+
console.log(`Generated ${outPath}`);
|
package/src/checker.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
getDiffFiles,
|
|
8
8
|
getRepoVisibility,
|
|
9
9
|
} from "./git";
|
|
10
|
+
import { withSpan } from "./telemetry";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Visibility チェック結果
|
|
@@ -24,20 +25,27 @@ export interface VisibilityCheckResult {
|
|
|
24
25
|
export async function checkVisibility(
|
|
25
26
|
allowedVisibility?: RepoVisibility[]
|
|
26
27
|
): Promise<VisibilityCheckResult | null> {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
return withSpan("safe-push.check.visibility", async (span) => {
|
|
29
|
+
if (!allowedVisibility || allowedVisibility.length === 0) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const visibility = await getRepoVisibility();
|
|
34
|
+
const allowed = allowedVisibility.includes(visibility as RepoVisibility);
|
|
30
35
|
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
span.addEvent("visibility.result", {
|
|
37
|
+
value: visibility,
|
|
38
|
+
allowed,
|
|
39
|
+
});
|
|
33
40
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
return {
|
|
42
|
+
allowed,
|
|
43
|
+
reason: allowed
|
|
44
|
+
? `Repository visibility "${visibility}" is allowed`
|
|
45
|
+
: `Repository visibility "${visibility}" is not in allowed list: [${allowedVisibility.join(", ")}]`,
|
|
46
|
+
visibility,
|
|
47
|
+
};
|
|
48
|
+
});
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
/**
|
|
@@ -88,58 +96,69 @@ function findForbiddenFiles(
|
|
|
88
96
|
* (禁止エリア変更なし) AND (新規ブランチ OR 最終コミットが自分)
|
|
89
97
|
*/
|
|
90
98
|
export async function checkPush(config: Config): Promise<CheckResult> {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
99
|
+
return withSpan("safe-push.check.push", async (span) => {
|
|
100
|
+
const currentBranch = await getCurrentBranch();
|
|
101
|
+
const newBranch = await isNewBranch();
|
|
102
|
+
const authorEmail = await getLastCommitAuthorEmail();
|
|
103
|
+
const localEmail = await getLocalEmail();
|
|
104
|
+
const diffFiles = await getDiffFiles();
|
|
96
105
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
106
|
+
const forbiddenFiles = findForbiddenFiles(diffFiles, config.forbiddenPaths);
|
|
107
|
+
const hasForbiddenChanges = forbiddenFiles.length > 0;
|
|
108
|
+
const isOwnLastCommit =
|
|
109
|
+
authorEmail.toLowerCase() === localEmail.toLowerCase();
|
|
101
110
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
// 禁止エリアに変更がある場合は常にブロック
|
|
113
|
-
if (hasForbiddenChanges) {
|
|
114
|
-
return {
|
|
115
|
-
allowed: false,
|
|
116
|
-
reason: `Forbidden files detected: ${forbiddenFiles.join(", ")}`,
|
|
117
|
-
details,
|
|
111
|
+
const details = {
|
|
112
|
+
isNewBranch: newBranch,
|
|
113
|
+
isOwnLastCommit,
|
|
114
|
+
hasForbiddenChanges,
|
|
115
|
+
forbiddenFiles,
|
|
116
|
+
currentBranch,
|
|
117
|
+
authorEmail,
|
|
118
|
+
localEmail,
|
|
118
119
|
};
|
|
119
|
-
}
|
|
120
120
|
|
|
121
|
-
|
|
122
|
-
if (newBranch) {
|
|
123
|
-
return {
|
|
124
|
-
allowed: true,
|
|
125
|
-
reason: "New branch - no restrictions",
|
|
126
|
-
details,
|
|
127
|
-
};
|
|
128
|
-
}
|
|
121
|
+
let result: CheckResult;
|
|
129
122
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
123
|
+
// 禁止エリアに変更がある場合は常にブロック
|
|
124
|
+
if (hasForbiddenChanges) {
|
|
125
|
+
result = {
|
|
126
|
+
allowed: false,
|
|
127
|
+
reason: `Forbidden files detected: ${forbiddenFiles.join(", ")}`,
|
|
128
|
+
details,
|
|
129
|
+
};
|
|
130
|
+
} else if (newBranch) {
|
|
131
|
+
// 新規ブランチの場合は許可
|
|
132
|
+
result = {
|
|
133
|
+
allowed: true,
|
|
134
|
+
reason: "New branch - no restrictions",
|
|
135
|
+
details,
|
|
136
|
+
};
|
|
137
|
+
} else if (isOwnLastCommit) {
|
|
138
|
+
// 最終コミットが自分の場合は許可
|
|
139
|
+
result = {
|
|
140
|
+
allowed: true,
|
|
141
|
+
reason: "Last commit is yours",
|
|
142
|
+
details,
|
|
143
|
+
};
|
|
144
|
+
} else {
|
|
145
|
+
// それ以外はブロック
|
|
146
|
+
result = {
|
|
147
|
+
allowed: false,
|
|
148
|
+
reason: `Last commit is by someone else (${authorEmail})`,
|
|
149
|
+
details,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
span.addEvent("check.result", {
|
|
154
|
+
allowed: result.allowed,
|
|
155
|
+
reason: result.reason,
|
|
156
|
+
isNewBranch: newBranch,
|
|
157
|
+
isOwnLastCommit,
|
|
158
|
+
hasForbiddenChanges,
|
|
159
|
+
forbiddenFileCount: forbiddenFiles.length,
|
|
160
|
+
});
|
|
138
161
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
allowed: false,
|
|
142
|
-
reason: `Last commit is by someone else (${authorEmail})`,
|
|
143
|
-
details,
|
|
144
|
-
};
|
|
162
|
+
return result;
|
|
163
|
+
});
|
|
145
164
|
}
|
package/src/commands/check.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { loadConfig } from "../config";
|
|
|
3
3
|
import { checkPush, checkVisibility } from "../checker";
|
|
4
4
|
import { isGitRepository, hasCommits } from "../git";
|
|
5
5
|
import { printError, printCheckResultJson, printCheckResultHuman } from "./utils";
|
|
6
|
+
import { ExitError } from "../types";
|
|
7
|
+
import { withSpan } from "../telemetry";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* checkコマンドを作成
|
|
@@ -12,20 +14,27 @@ export function createCheckCommand(): Command {
|
|
|
12
14
|
.description("Check if push is allowed")
|
|
13
15
|
.option("--json", "Output result as JSON")
|
|
14
16
|
.action(async (options: { json?: boolean }) => {
|
|
15
|
-
|
|
17
|
+
await withSpan("safe-push.check", async (rootSpan) => {
|
|
16
18
|
// Gitリポジトリ内か確認
|
|
17
19
|
if (!(await isGitRepository())) {
|
|
18
20
|
printError("Not a git repository");
|
|
19
|
-
|
|
21
|
+
throw new ExitError(1);
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
// コミットが存在するか確認
|
|
23
25
|
if (!(await hasCommits())) {
|
|
24
26
|
printError("No commits found");
|
|
25
|
-
|
|
27
|
+
throw new ExitError(1);
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
const config = loadConfig();
|
|
31
|
+
|
|
32
|
+
rootSpan.addEvent("config.loaded", {
|
|
33
|
+
forbiddenPaths: JSON.stringify(config.forbiddenPaths),
|
|
34
|
+
onForbidden: config.onForbidden,
|
|
35
|
+
hasVisibilityRule: !!(config.allowedVisibility && config.allowedVisibility.length > 0),
|
|
36
|
+
});
|
|
37
|
+
|
|
29
38
|
const result = await checkPush(config);
|
|
30
39
|
|
|
31
40
|
// visibility チェック
|
|
@@ -41,6 +50,7 @@ export function createCheckCommand(): Command {
|
|
|
41
50
|
}
|
|
42
51
|
}
|
|
43
52
|
} catch (error) {
|
|
53
|
+
if (error instanceof ExitError) throw error;
|
|
44
54
|
result.details.repoVisibility = "unknown";
|
|
45
55
|
result.details.visibilityAllowed = false;
|
|
46
56
|
result.allowed = false;
|
|
@@ -54,12 +64,9 @@ export function createCheckCommand(): Command {
|
|
|
54
64
|
printCheckResultHuman(result);
|
|
55
65
|
}
|
|
56
66
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
);
|
|
62
|
-
process.exit(1);
|
|
63
|
-
}
|
|
67
|
+
if (!result.allowed) {
|
|
68
|
+
throw new ExitError(1);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
64
71
|
});
|
|
65
72
|
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
5
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
6
|
+
import {
|
|
7
|
+
createServer,
|
|
8
|
+
type IncomingMessage,
|
|
9
|
+
type ServerResponse,
|
|
10
|
+
} from "node:http";
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { loadConfig } from "../config";
|
|
15
|
+
import { checkPush, checkVisibility } from "../checker";
|
|
16
|
+
import { isGitRepository, hasCommits, execPush } from "../git";
|
|
17
|
+
|
|
18
|
+
function createMcpServer(): McpServer {
|
|
19
|
+
const server = new McpServer({
|
|
20
|
+
name: "safe-push",
|
|
21
|
+
version: "0.3.0",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
server.tool(
|
|
25
|
+
"push",
|
|
26
|
+
"Run safety checks and execute git push. Checks forbidden paths, branch ownership, and repository visibility before pushing.",
|
|
27
|
+
{
|
|
28
|
+
force: z
|
|
29
|
+
.boolean()
|
|
30
|
+
.optional()
|
|
31
|
+
.describe(
|
|
32
|
+
"Bypass safety checks (except visibility). Use when a previous push was blocked and you want to override."
|
|
33
|
+
),
|
|
34
|
+
dryRun: z
|
|
35
|
+
.boolean()
|
|
36
|
+
.optional()
|
|
37
|
+
.describe("Show what would be pushed without actually pushing."),
|
|
38
|
+
args: z
|
|
39
|
+
.array(z.string())
|
|
40
|
+
.optional()
|
|
41
|
+
.describe(
|
|
42
|
+
"Additional git push arguments (e.g. remote name, refspec, flags like --no-verify)."
|
|
43
|
+
),
|
|
44
|
+
},
|
|
45
|
+
async ({ force, dryRun, args }) => {
|
|
46
|
+
try {
|
|
47
|
+
// SAFE_PUSH_GIT_ROOT が設定されている場合は chdir
|
|
48
|
+
const gitRoot = process.env.SAFE_PUSH_GIT_ROOT;
|
|
49
|
+
if (gitRoot) {
|
|
50
|
+
process.chdir(path.resolve(gitRoot));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Git リポジトリ内か確認
|
|
54
|
+
if (!(await isGitRepository())) {
|
|
55
|
+
return {
|
|
56
|
+
content: [
|
|
57
|
+
{
|
|
58
|
+
type: "text" as const,
|
|
59
|
+
text: "Error: Not a git repository",
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
isError: true,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// コミットが存在するか確認
|
|
67
|
+
if (!(await hasCommits())) {
|
|
68
|
+
return {
|
|
69
|
+
content: [
|
|
70
|
+
{
|
|
71
|
+
type: "text" as const,
|
|
72
|
+
text: "Error: No commits found",
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
isError: true,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const config = loadConfig();
|
|
80
|
+
const gitArgs = args ?? [];
|
|
81
|
+
|
|
82
|
+
// visibility チェック(force でもバイパス不可)
|
|
83
|
+
if (config.allowedVisibility && config.allowedVisibility.length > 0) {
|
|
84
|
+
try {
|
|
85
|
+
const visibilityResult = await checkVisibility(
|
|
86
|
+
config.allowedVisibility
|
|
87
|
+
);
|
|
88
|
+
if (visibilityResult && !visibilityResult.allowed) {
|
|
89
|
+
return {
|
|
90
|
+
content: [
|
|
91
|
+
{
|
|
92
|
+
type: "text" as const,
|
|
93
|
+
text: `Blocked: ${visibilityResult.reason}`,
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
isError: true,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
} catch (error) {
|
|
100
|
+
return {
|
|
101
|
+
content: [
|
|
102
|
+
{
|
|
103
|
+
type: "text" as const,
|
|
104
|
+
text: `Failed to check repository visibility. Ensure 'gh' CLI is installed and authenticated.\n${error instanceof Error ? error.message : String(error)}`,
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
isError: true,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// force の場合はチェックをスキップ
|
|
113
|
+
if (force) {
|
|
114
|
+
if (dryRun) {
|
|
115
|
+
return {
|
|
116
|
+
content: [
|
|
117
|
+
{
|
|
118
|
+
type: "text" as const,
|
|
119
|
+
text: "Dry run: would push (checks bypassed with force)",
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const result = await execPush(gitArgs);
|
|
126
|
+
if (result.success) {
|
|
127
|
+
return {
|
|
128
|
+
content: [
|
|
129
|
+
{
|
|
130
|
+
type: "text" as const,
|
|
131
|
+
text: result.output
|
|
132
|
+
? `Push successful (force)\n${result.output}`
|
|
133
|
+
: "Push successful (force)",
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
content: [
|
|
140
|
+
{
|
|
141
|
+
type: "text" as const,
|
|
142
|
+
text: `Push failed: ${result.output}`,
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
isError: true,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 通常のチェック
|
|
150
|
+
const checkResult = await checkPush(config);
|
|
151
|
+
|
|
152
|
+
if (!checkResult.allowed) {
|
|
153
|
+
const details = checkResult.details;
|
|
154
|
+
let message = `Push blocked: ${checkResult.reason}\n\nDetails:\n- Branch: ${details.currentBranch}\n- New branch: ${details.isNewBranch}\n- Own last commit: ${details.isOwnLastCommit}\n- Author: ${details.authorEmail}\n- Local: ${details.localEmail}`;
|
|
155
|
+
|
|
156
|
+
if (details.forbiddenFiles.length > 0) {
|
|
157
|
+
message += `\n- Forbidden files: ${details.forbiddenFiles.join(", ")}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// onForbidden: "prompt" の場合、MCP ではインタラクティブ確認不可なので force を案内
|
|
161
|
+
if (
|
|
162
|
+
config.onForbidden === "prompt" &&
|
|
163
|
+
details.hasForbiddenChanges
|
|
164
|
+
) {
|
|
165
|
+
message +=
|
|
166
|
+
'\n\nThis repository is configured with onForbidden: "prompt". Since interactive confirmation is not available via MCP, you can re-run with force: true to bypass this check.';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
content: [
|
|
171
|
+
{
|
|
172
|
+
type: "text" as const,
|
|
173
|
+
text: message,
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
isError: true,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// チェック通過
|
|
181
|
+
if (dryRun) {
|
|
182
|
+
const details = checkResult.details;
|
|
183
|
+
return {
|
|
184
|
+
content: [
|
|
185
|
+
{
|
|
186
|
+
type: "text" as const,
|
|
187
|
+
text: `Dry run: would push\nReason: ${checkResult.reason}\nBranch: ${details.currentBranch}`,
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// push 実行
|
|
194
|
+
const result = await execPush(gitArgs);
|
|
195
|
+
if (result.success) {
|
|
196
|
+
return {
|
|
197
|
+
content: [
|
|
198
|
+
{
|
|
199
|
+
type: "text" as const,
|
|
200
|
+
text: result.output
|
|
201
|
+
? `Push successful\n${result.output}`
|
|
202
|
+
: "Push successful",
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
content: [
|
|
209
|
+
{
|
|
210
|
+
type: "text" as const,
|
|
211
|
+
text: `Push failed: ${result.output}`,
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
isError: true,
|
|
215
|
+
};
|
|
216
|
+
} catch (error) {
|
|
217
|
+
return {
|
|
218
|
+
content: [
|
|
219
|
+
{
|
|
220
|
+
type: "text" as const,
|
|
221
|
+
text: `Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
isError: true,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
return server;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function startStdioServer(): Promise<void> {
|
|
234
|
+
const server = createMcpServer();
|
|
235
|
+
const transport = new StdioServerTransport();
|
|
236
|
+
await server.connect(transport);
|
|
237
|
+
console.error("safe-push MCP server started (stdio)");
|
|
238
|
+
await new Promise<void>((resolve) => {
|
|
239
|
+
transport.onclose = () => resolve();
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function startHttpServer(port: number): Promise<void> {
|
|
244
|
+
const transports = new Map<string, StreamableHTTPServerTransport>();
|
|
245
|
+
|
|
246
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
247
|
+
return new Promise((resolve, reject) => {
|
|
248
|
+
let data = "";
|
|
249
|
+
req.on("data", (chunk: Buffer) => {
|
|
250
|
+
data += chunk.toString();
|
|
251
|
+
});
|
|
252
|
+
req.on("end", () => resolve(data));
|
|
253
|
+
req.on("error", reject);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const httpServer = createServer(
|
|
258
|
+
async (req: IncomingMessage, res: ServerResponse) => {
|
|
259
|
+
const url = new URL(
|
|
260
|
+
req.url ?? "/",
|
|
261
|
+
`http://${req.headers.host ?? "localhost"}`
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
if (url.pathname !== "/mcp") {
|
|
265
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
266
|
+
res.end(JSON.stringify({ error: "Not Found" }));
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const method = req.method?.toUpperCase();
|
|
271
|
+
|
|
272
|
+
if (method === "POST") {
|
|
273
|
+
try {
|
|
274
|
+
const body = await readBody(req);
|
|
275
|
+
const parsedBody = JSON.parse(body);
|
|
276
|
+
const sessionId = req.headers["mcp-session-id"] as
|
|
277
|
+
| string
|
|
278
|
+
| undefined;
|
|
279
|
+
|
|
280
|
+
let transport: StreamableHTTPServerTransport;
|
|
281
|
+
|
|
282
|
+
if (sessionId && transports.has(sessionId)) {
|
|
283
|
+
transport = transports.get(sessionId)!;
|
|
284
|
+
} else if (!sessionId && isInitializeRequest(parsedBody)) {
|
|
285
|
+
transport = new StreamableHTTPServerTransport({
|
|
286
|
+
sessionIdGenerator: () => randomUUID(),
|
|
287
|
+
onsessioninitialized: (sid: string) => {
|
|
288
|
+
transports.set(sid, transport);
|
|
289
|
+
console.error(`Session initialized: ${sid}`);
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
transport.onclose = () => {
|
|
294
|
+
const sid = transport.sessionId;
|
|
295
|
+
if (sid) {
|
|
296
|
+
transports.delete(sid);
|
|
297
|
+
console.error(`Session closed: ${sid}`);
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const server = createMcpServer();
|
|
302
|
+
await server.connect(transport);
|
|
303
|
+
await transport.handleRequest(req, res, parsedBody);
|
|
304
|
+
return;
|
|
305
|
+
} else {
|
|
306
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
307
|
+
res.end(
|
|
308
|
+
JSON.stringify({
|
|
309
|
+
jsonrpc: "2.0",
|
|
310
|
+
error: {
|
|
311
|
+
code: -32000,
|
|
312
|
+
message: "Bad Request: No valid session ID provided",
|
|
313
|
+
},
|
|
314
|
+
id: null,
|
|
315
|
+
})
|
|
316
|
+
);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
await transport.handleRequest(req, res, parsedBody);
|
|
321
|
+
} catch {
|
|
322
|
+
if (!res.headersSent) {
|
|
323
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
324
|
+
res.end(
|
|
325
|
+
JSON.stringify({
|
|
326
|
+
jsonrpc: "2.0",
|
|
327
|
+
error: { code: -32603, message: "Internal server error" },
|
|
328
|
+
id: null,
|
|
329
|
+
})
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
} else if (method === "GET") {
|
|
334
|
+
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
|
335
|
+
if (!sessionId || !transports.has(sessionId)) {
|
|
336
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
337
|
+
res.end("Invalid or missing session ID");
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const transport = transports.get(sessionId)!;
|
|
341
|
+
await transport.handleRequest(req, res);
|
|
342
|
+
} else if (method === "DELETE") {
|
|
343
|
+
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
|
344
|
+
if (!sessionId || !transports.has(sessionId)) {
|
|
345
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
346
|
+
res.end("Invalid or missing session ID");
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const transport = transports.get(sessionId)!;
|
|
350
|
+
await transport.handleRequest(req, res);
|
|
351
|
+
} else {
|
|
352
|
+
res.writeHead(405, { "Content-Type": "text/plain" });
|
|
353
|
+
res.end("Method Not Allowed");
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
await new Promise<void>((resolve) => {
|
|
359
|
+
httpServer.listen(port, () => {
|
|
360
|
+
console.error(`safe-push MCP server started (HTTP) on port ${port}`);
|
|
361
|
+
});
|
|
362
|
+
httpServer.on("close", resolve);
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function createMcpCommand(): Command {
|
|
367
|
+
const cmd = new Command("mcp")
|
|
368
|
+
.description("Start MCP server for Claude Code integration")
|
|
369
|
+
.option("--http", "Start as HTTP server instead of stdio")
|
|
370
|
+
.option("--port <number>", "HTTP server port (default: PORT env or 3000)")
|
|
371
|
+
.action(async (opts: { http?: boolean; port?: string }) => {
|
|
372
|
+
if (opts.http) {
|
|
373
|
+
const port = opts.port
|
|
374
|
+
? Number(opts.port)
|
|
375
|
+
: Number(process.env.PORT) || 3000;
|
|
376
|
+
await startHttpServer(port);
|
|
377
|
+
} else {
|
|
378
|
+
await startStdioServer();
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
return cmd;
|
|
383
|
+
}
|