gitlab-mcp 1.1.0 → 1.2.1
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/LICENSE +21 -0
- package/README.md +12 -1
- package/dist/config/dotenv.d.ts +2 -0
- package/dist/config/dotenv.js +40 -0
- package/dist/config/dotenv.js.map +1 -0
- package/dist/config/env.d.ts +55 -0
- package/dist/config/env.js +164 -0
- package/dist/config/env.js.map +1 -0
- package/dist/http-app.d.ts +45 -0
- package/dist/http-app.js +550 -0
- package/dist/http-app.js.map +1 -0
- package/dist/http.d.ts +2 -0
- package/dist/http.js +65 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +65 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/auth-context.d.ts +9 -0
- package/dist/lib/auth-context.js +9 -0
- package/dist/lib/auth-context.js.map +1 -0
- package/dist/lib/gitlab-client.d.ts +331 -0
- package/dist/lib/gitlab-client.js +1025 -0
- package/dist/lib/gitlab-client.js.map +1 -0
- package/dist/lib/logger.d.ts +2 -0
- package/dist/lib/logger.js +13 -0
- package/dist/lib/logger.js.map +1 -0
- package/dist/lib/network.d.ts +3 -0
- package/dist/lib/network.js +38 -0
- package/dist/lib/network.js.map +1 -0
- package/dist/lib/oauth.d.ts +29 -0
- package/dist/lib/oauth.js +220 -0
- package/dist/lib/oauth.js.map +1 -0
- package/dist/lib/output.d.ts +14 -0
- package/dist/lib/output.js +38 -0
- package/dist/lib/output.js.map +1 -0
- package/dist/lib/policy.d.ts +25 -0
- package/dist/lib/policy.js +48 -0
- package/dist/lib/policy.js.map +1 -0
- package/dist/lib/request-runtime.d.ts +26 -0
- package/dist/lib/request-runtime.js +323 -0
- package/dist/lib/request-runtime.js.map +1 -0
- package/dist/lib/sanitize.d.ts +1 -0
- package/dist/lib/sanitize.js +21 -0
- package/dist/lib/sanitize.js.map +1 -0
- package/dist/lib/session-capacity.d.ts +8 -0
- package/dist/lib/session-capacity.js +7 -0
- package/dist/lib/session-capacity.js.map +1 -0
- package/dist/server/build-server.d.ts +3 -0
- package/dist/server/build-server.js +13 -0
- package/dist/server/build-server.js.map +1 -0
- package/dist/tools/gitlab.d.ts +9 -0
- package/dist/tools/gitlab.js +2576 -0
- package/dist/tools/gitlab.js.map +1 -0
- package/dist/tools/health.d.ts +2 -0
- package/dist/tools/health.js +21 -0
- package/dist/tools/health.js.map +1 -0
- package/dist/tools/mr-code-context.d.ts +38 -0
- package/dist/tools/mr-code-context.js +330 -0
- package/dist/tools/mr-code-context.js.map +1 -0
- package/{src/types/context.ts → dist/types/context.d.ts} +5 -6
- package/dist/types/context.js +2 -0
- package/dist/types/context.js.map +1 -0
- package/docs/architecture.md +10 -10
- package/docs/configuration.md +12 -7
- package/docs/mcp-integration-testing-best-practices.md +981 -0
- package/package.json +13 -1
- package/.dockerignore +0 -7
- package/.editorconfig +0 -9
- package/.env.example +0 -75
- package/.github/workflows/nodejs.yml +0 -31
- package/.github/workflows/npm-publish.yml +0 -31
- package/.husky/pre-commit +0 -1
- package/.nvmrc +0 -1
- package/.prettierrc.json +0 -6
- package/Dockerfile +0 -20
- package/docker-compose.yml +0 -10
- package/eslint.config.js +0 -23
- package/scripts/get-oauth-token.example.sh +0 -15
- package/src/config/env.ts +0 -171
- package/src/http.ts +0 -620
- package/src/index.ts +0 -77
- package/src/lib/auth-context.ts +0 -19
- package/src/lib/gitlab-client.ts +0 -1810
- package/src/lib/logger.ts +0 -17
- package/src/lib/network.ts +0 -45
- package/src/lib/oauth.ts +0 -287
- package/src/lib/output.ts +0 -51
- package/src/lib/policy.ts +0 -78
- package/src/lib/request-runtime.ts +0 -376
- package/src/lib/sanitize.ts +0 -25
- package/src/lib/session-capacity.ts +0 -14
- package/src/server/build-server.ts +0 -17
- package/src/tools/gitlab.ts +0 -3135
- package/src/tools/health.ts +0 -27
- package/src/tools/mr-code-context.ts +0 -473
- package/tests/auth-context.test.ts +0 -102
- package/tests/gitlab-client.test.ts +0 -672
- package/tests/graphql-guard.test.ts +0 -121
- package/tests/integration/agent-loop.integration.test.ts +0 -558
- package/tests/integration/server.integration.test.ts +0 -543
- package/tests/mr-code-context.test.ts +0 -600
- package/tests/oauth.test.ts +0 -43
- package/tests/output.test.ts +0 -186
- package/tests/policy.test.ts +0 -324
- package/tests/request-runtime.test.ts +0 -252
- package/tests/sanitize.test.ts +0 -123
- package/tests/session-capacity.test.ts +0 -49
- package/tests/upload-reference.test.ts +0 -88
- package/tsconfig.build.json +0 -11
- package/tsconfig.json +0 -21
- package/vitest.config.ts +0 -12
package/src/lib/logger.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import pino from "pino";
|
|
2
|
-
|
|
3
|
-
import { env } from "../config/env.js";
|
|
4
|
-
|
|
5
|
-
export const logger = pino(
|
|
6
|
-
{
|
|
7
|
-
name: env.MCP_SERVER_NAME,
|
|
8
|
-
level: env.LOG_LEVEL,
|
|
9
|
-
redact: [
|
|
10
|
-
"req.headers.authorization",
|
|
11
|
-
"req.headers.private-token",
|
|
12
|
-
"config.GITLAB_TOKEN",
|
|
13
|
-
"token"
|
|
14
|
-
]
|
|
15
|
-
},
|
|
16
|
-
pino.destination({ fd: 2, sync: false })
|
|
17
|
-
);
|
package/src/lib/network.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import * as fs from "node:fs";
|
|
2
|
-
|
|
3
|
-
import type { Logger } from "pino";
|
|
4
|
-
import { Agent, ProxyAgent, setGlobalDispatcher } from "undici";
|
|
5
|
-
|
|
6
|
-
import type { AppEnv } from "../config/env.js";
|
|
7
|
-
|
|
8
|
-
export function configureNetworkRuntime(env: AppEnv, logger: Logger): void {
|
|
9
|
-
const rejectUnauthorized = env.NODE_TLS_REJECT_UNAUTHORIZED !== "0";
|
|
10
|
-
const proxyUrl = env.HTTPS_PROXY || env.HTTP_PROXY;
|
|
11
|
-
const connectOptions = {
|
|
12
|
-
rejectUnauthorized,
|
|
13
|
-
ca: loadCaBundle(env, logger)
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
if (proxyUrl) {
|
|
17
|
-
const proxyDispatcher = new ProxyAgent({
|
|
18
|
-
uri: proxyUrl,
|
|
19
|
-
requestTls: connectOptions
|
|
20
|
-
});
|
|
21
|
-
setGlobalDispatcher(proxyDispatcher);
|
|
22
|
-
logger.info({ proxyUrl, rejectUnauthorized }, "Configured global proxy dispatcher");
|
|
23
|
-
return;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const agent = new Agent({
|
|
27
|
-
connect: connectOptions
|
|
28
|
-
});
|
|
29
|
-
setGlobalDispatcher(agent);
|
|
30
|
-
logger.info({ rejectUnauthorized }, "Configured global network agent");
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function loadCaBundle(env: AppEnv, logger: Logger): string | undefined {
|
|
34
|
-
const caPath = env.GITLAB_CA_CERT_PATH?.trim();
|
|
35
|
-
if (!caPath) {
|
|
36
|
-
return undefined;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
try {
|
|
40
|
-
return fs.readFileSync(caPath, "utf8");
|
|
41
|
-
} catch (error) {
|
|
42
|
-
logger.warn({ err: error, caPath }, "Failed to load GITLAB_CA_CERT_PATH");
|
|
43
|
-
return undefined;
|
|
44
|
-
}
|
|
45
|
-
}
|
package/src/lib/oauth.ts
DELETED
|
@@ -1,287 +0,0 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
2
|
-
import * as fs from "node:fs/promises";
|
|
3
|
-
import * as http from "node:http";
|
|
4
|
-
import * as os from "node:os";
|
|
5
|
-
import * as path from "node:path";
|
|
6
|
-
|
|
7
|
-
import open from "open";
|
|
8
|
-
import pkceChallenge from "pkce-challenge";
|
|
9
|
-
import type { Logger } from "pino";
|
|
10
|
-
|
|
11
|
-
export interface GitLabOAuthConfig {
|
|
12
|
-
clientId: string;
|
|
13
|
-
clientSecret?: string;
|
|
14
|
-
gitlabUrl: string;
|
|
15
|
-
redirectUri: string;
|
|
16
|
-
scopes: string[];
|
|
17
|
-
tokenStoragePath?: string;
|
|
18
|
-
autoOpenBrowser: boolean;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
interface OAuthTokenData {
|
|
22
|
-
access_token: string;
|
|
23
|
-
token_type: string;
|
|
24
|
-
refresh_token?: string;
|
|
25
|
-
expires_in?: number;
|
|
26
|
-
created_at: number;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
interface TokenResponse {
|
|
30
|
-
access_token: string;
|
|
31
|
-
token_type: string;
|
|
32
|
-
refresh_token?: string;
|
|
33
|
-
expires_in?: number;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export class GitLabOAuthManager {
|
|
37
|
-
private readonly tokenPath: string;
|
|
38
|
-
private readonly callbackTimeoutMs = 180_000;
|
|
39
|
-
private inFlightTokenRequest: Promise<string | undefined> | null = null;
|
|
40
|
-
|
|
41
|
-
constructor(
|
|
42
|
-
private readonly config: GitLabOAuthConfig,
|
|
43
|
-
private readonly logger: Logger
|
|
44
|
-
) {
|
|
45
|
-
this.tokenPath =
|
|
46
|
-
config.tokenStoragePath || path.join(os.homedir(), ".gitlab-mcp-oauth-token.json");
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async getAccessToken(): Promise<string | undefined> {
|
|
50
|
-
if (this.inFlightTokenRequest) {
|
|
51
|
-
return this.inFlightTokenRequest;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
this.inFlightTokenRequest = this.resolveAccessToken().finally(() => {
|
|
55
|
-
this.inFlightTokenRequest = null;
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
return this.inFlightTokenRequest;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
private async resolveAccessToken(): Promise<string | undefined> {
|
|
62
|
-
const stored = await this.readStoredToken();
|
|
63
|
-
if (stored && !isExpired(stored)) {
|
|
64
|
-
return stored.access_token;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (stored?.refresh_token) {
|
|
68
|
-
try {
|
|
69
|
-
const refreshed = await this.refreshToken(stored.refresh_token);
|
|
70
|
-
await this.persistToken(refreshed);
|
|
71
|
-
return refreshed.access_token;
|
|
72
|
-
} catch (error) {
|
|
73
|
-
this.logger.warn({ err: error }, "OAuth token refresh failed; running interactive auth");
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const token = await this.runInteractiveAuthorization();
|
|
78
|
-
await this.persistToken(token);
|
|
79
|
-
return token.access_token;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
private async runInteractiveAuthorization(): Promise<OAuthTokenData> {
|
|
83
|
-
const redirectUrl = new URL(this.config.redirectUri);
|
|
84
|
-
if (redirectUrl.protocol !== "http:") {
|
|
85
|
-
throw new Error("Only http redirect URI is supported for local OAuth callback server");
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const challenge = await pkceChallenge();
|
|
89
|
-
const state = randomBytes(16).toString("hex");
|
|
90
|
-
const authorizationUrl = this.buildAuthorizationUrl({
|
|
91
|
-
state,
|
|
92
|
-
codeChallenge: challenge.code_challenge
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
if (this.config.autoOpenBrowser) {
|
|
96
|
-
void open(authorizationUrl.toString()).catch((error) => {
|
|
97
|
-
this.logger.warn(
|
|
98
|
-
{ err: error, authorizationUrl: authorizationUrl.toString() },
|
|
99
|
-
"Failed to open browser"
|
|
100
|
-
);
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
this.logger.info(
|
|
105
|
-
{ authorizationUrl: authorizationUrl.toString() },
|
|
106
|
-
"OAuth authorization required"
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
const code = await this.waitForAuthorizationCode(redirectUrl, state);
|
|
110
|
-
return this.exchangeCodeForToken(code, challenge.code_verifier);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
private buildAuthorizationUrl(options: { state: string; codeChallenge: string }): URL {
|
|
114
|
-
const url = new URL("/oauth/authorize", this.config.gitlabUrl);
|
|
115
|
-
url.searchParams.set("client_id", this.config.clientId);
|
|
116
|
-
url.searchParams.set("redirect_uri", this.config.redirectUri);
|
|
117
|
-
url.searchParams.set("response_type", "code");
|
|
118
|
-
url.searchParams.set("scope", this.config.scopes.join(" "));
|
|
119
|
-
url.searchParams.set("state", options.state);
|
|
120
|
-
url.searchParams.set("code_challenge", options.codeChallenge);
|
|
121
|
-
url.searchParams.set("code_challenge_method", "S256");
|
|
122
|
-
return url;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
private async waitForAuthorizationCode(redirectUrl: URL, expectedState: string): Promise<string> {
|
|
126
|
-
const port = Number.parseInt(redirectUrl.port || "80", 10);
|
|
127
|
-
const hostname = redirectUrl.hostname;
|
|
128
|
-
const callbackPath = redirectUrl.pathname;
|
|
129
|
-
|
|
130
|
-
return new Promise<string>((resolve, reject) => {
|
|
131
|
-
let settled = false;
|
|
132
|
-
|
|
133
|
-
const finalize = (fn: () => void) => {
|
|
134
|
-
if (settled) {
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
settled = true;
|
|
138
|
-
clearTimeout(timeout);
|
|
139
|
-
fn();
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
const server = http.createServer((req, res) => {
|
|
143
|
-
const host = req.headers.host || `${hostname}:${port}`;
|
|
144
|
-
const requestUrl = new URL(req.url || "/", `http://${host}`);
|
|
145
|
-
if (requestUrl.pathname !== callbackPath) {
|
|
146
|
-
res.statusCode = 404;
|
|
147
|
-
res.end("Not found");
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const code = requestUrl.searchParams.get("code");
|
|
152
|
-
const state = requestUrl.searchParams.get("state");
|
|
153
|
-
if (!code || state !== expectedState) {
|
|
154
|
-
res.statusCode = 400;
|
|
155
|
-
res.end("Invalid OAuth callback");
|
|
156
|
-
finalize(() => {
|
|
157
|
-
server.close();
|
|
158
|
-
reject(new Error("Invalid OAuth callback state or missing code"));
|
|
159
|
-
});
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
res.statusCode = 200;
|
|
164
|
-
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
165
|
-
res.end(
|
|
166
|
-
"<html><body><h3>Authentication complete. You can close this tab.</h3></body></html>"
|
|
167
|
-
);
|
|
168
|
-
finalize(() => {
|
|
169
|
-
server.close();
|
|
170
|
-
resolve(code);
|
|
171
|
-
});
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
server.on("error", (error) => {
|
|
175
|
-
finalize(() => reject(error));
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
const timeout = setTimeout(() => {
|
|
179
|
-
finalize(() => {
|
|
180
|
-
server.close();
|
|
181
|
-
reject(new Error("OAuth callback timeout"));
|
|
182
|
-
});
|
|
183
|
-
}, this.callbackTimeoutMs);
|
|
184
|
-
|
|
185
|
-
server.listen(port, hostname);
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
private async exchangeCodeForToken(code: string, codeVerifier: string): Promise<OAuthTokenData> {
|
|
190
|
-
const endpoint = new URL("/oauth/token", this.config.gitlabUrl);
|
|
191
|
-
const body = new URLSearchParams({
|
|
192
|
-
client_id: this.config.clientId,
|
|
193
|
-
grant_type: "authorization_code",
|
|
194
|
-
code,
|
|
195
|
-
redirect_uri: this.config.redirectUri,
|
|
196
|
-
code_verifier: codeVerifier
|
|
197
|
-
});
|
|
198
|
-
if (this.config.clientSecret) {
|
|
199
|
-
body.set("client_secret", this.config.clientSecret);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
return this.fetchToken(endpoint, body);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
private async refreshToken(refreshToken: string): Promise<OAuthTokenData> {
|
|
206
|
-
const endpoint = new URL("/oauth/token", this.config.gitlabUrl);
|
|
207
|
-
const body = new URLSearchParams({
|
|
208
|
-
client_id: this.config.clientId,
|
|
209
|
-
grant_type: "refresh_token",
|
|
210
|
-
refresh_token: refreshToken,
|
|
211
|
-
redirect_uri: this.config.redirectUri
|
|
212
|
-
});
|
|
213
|
-
if (this.config.clientSecret) {
|
|
214
|
-
body.set("client_secret", this.config.clientSecret);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
return this.fetchToken(endpoint, body);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
private async fetchToken(endpoint: URL, body: URLSearchParams): Promise<OAuthTokenData> {
|
|
221
|
-
const response = await fetch(endpoint, {
|
|
222
|
-
method: "POST",
|
|
223
|
-
headers: {
|
|
224
|
-
"Content-Type": "application/x-www-form-urlencoded"
|
|
225
|
-
},
|
|
226
|
-
body: body.toString()
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
if (!response.ok) {
|
|
230
|
-
const raw = await response.text();
|
|
231
|
-
throw new Error(`OAuth token endpoint failed: ${response.status} ${raw.slice(0, 500)}`);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
const payload = (await response.json()) as TokenResponse;
|
|
235
|
-
if (!payload.access_token) {
|
|
236
|
-
throw new Error("OAuth response did not include access_token");
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
return {
|
|
240
|
-
access_token: payload.access_token,
|
|
241
|
-
token_type: payload.token_type || "Bearer",
|
|
242
|
-
refresh_token: payload.refresh_token,
|
|
243
|
-
expires_in: payload.expires_in,
|
|
244
|
-
created_at: Date.now()
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
private async persistToken(token: OAuthTokenData): Promise<void> {
|
|
249
|
-
const dir = path.dirname(this.tokenPath);
|
|
250
|
-
await fs.mkdir(dir, { recursive: true });
|
|
251
|
-
await fs.writeFile(this.tokenPath, JSON.stringify(token, null, 2), { mode: 0o600 });
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
private async readStoredToken(): Promise<OAuthTokenData | undefined> {
|
|
255
|
-
try {
|
|
256
|
-
const raw = await fs.readFile(this.tokenPath, "utf8");
|
|
257
|
-
const parsed = JSON.parse(raw) as Partial<OAuthTokenData>;
|
|
258
|
-
if (!parsed.access_token) {
|
|
259
|
-
return undefined;
|
|
260
|
-
}
|
|
261
|
-
return {
|
|
262
|
-
access_token: parsed.access_token,
|
|
263
|
-
token_type: parsed.token_type || "Bearer",
|
|
264
|
-
refresh_token: parsed.refresh_token,
|
|
265
|
-
expires_in: parsed.expires_in,
|
|
266
|
-
created_at: parsed.created_at || 0
|
|
267
|
-
};
|
|
268
|
-
} catch {
|
|
269
|
-
return undefined;
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
function isExpired(token: OAuthTokenData): boolean {
|
|
275
|
-
if (!token.expires_in) {
|
|
276
|
-
return false;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const expiresAt = token.created_at + token.expires_in * 1000;
|
|
280
|
-
return Date.now() >= expiresAt - 5 * 60 * 1000;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
export function deriveGitLabBaseUrl(apiUrl: string): string {
|
|
284
|
-
const url = new URL(apiUrl);
|
|
285
|
-
const prefix = url.pathname.replace(/\/api\/v4\/?$/, "");
|
|
286
|
-
return new URL(prefix || "/", url.origin).toString().replace(/\/$/, "");
|
|
287
|
-
}
|
package/src/lib/output.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import { stringify as toYaml } from "yaml";
|
|
2
|
-
|
|
3
|
-
export interface FormatOptions {
|
|
4
|
-
responseMode: "json" | "compact-json" | "yaml";
|
|
5
|
-
maxBytes: number;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export interface FormattedPayload {
|
|
9
|
-
text: string;
|
|
10
|
-
truncated: boolean;
|
|
11
|
-
bytes: number;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export class OutputFormatter {
|
|
15
|
-
constructor(private readonly options: FormatOptions) {}
|
|
16
|
-
|
|
17
|
-
format(value: unknown): FormattedPayload {
|
|
18
|
-
const serialized = serializeValue(value, this.options.responseMode);
|
|
19
|
-
const bytes = Buffer.byteLength(serialized, "utf8");
|
|
20
|
-
|
|
21
|
-
if (bytes <= this.options.maxBytes) {
|
|
22
|
-
return {
|
|
23
|
-
text: serialized,
|
|
24
|
-
truncated: false,
|
|
25
|
-
bytes
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const buffer = Buffer.from(serialized, "utf8");
|
|
30
|
-
const head = buffer.subarray(0, this.options.maxBytes);
|
|
31
|
-
const truncatedBytes = bytes - this.options.maxBytes;
|
|
32
|
-
|
|
33
|
-
return {
|
|
34
|
-
text: `${head.toString("utf8")}\n... [truncated ${truncatedBytes} bytes]`,
|
|
35
|
-
truncated: true,
|
|
36
|
-
bytes
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function serializeValue(value: unknown, mode: FormatOptions["responseMode"]): string {
|
|
42
|
-
switch (mode) {
|
|
43
|
-
case "compact-json":
|
|
44
|
-
return JSON.stringify(value);
|
|
45
|
-
case "yaml":
|
|
46
|
-
return toYaml(value);
|
|
47
|
-
case "json":
|
|
48
|
-
default:
|
|
49
|
-
return JSON.stringify(value, null, 2);
|
|
50
|
-
}
|
|
51
|
-
}
|
package/src/lib/policy.ts
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
export interface ToolPolicyMeta {
|
|
2
|
-
name: string;
|
|
3
|
-
mutating: boolean;
|
|
4
|
-
requiresFeature?: "wiki" | "milestone" | "pipeline" | "release";
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
export interface ToolPolicyConfig {
|
|
8
|
-
readOnlyMode: boolean;
|
|
9
|
-
allowedTools: string[];
|
|
10
|
-
deniedToolsRegex?: RegExp;
|
|
11
|
-
enabledFeatures: {
|
|
12
|
-
wiki: boolean;
|
|
13
|
-
milestone: boolean;
|
|
14
|
-
pipeline: boolean;
|
|
15
|
-
release: boolean;
|
|
16
|
-
};
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export class ToolPolicyEngine {
|
|
20
|
-
private readonly normalizedAllowedTools: Set<string>;
|
|
21
|
-
|
|
22
|
-
constructor(private readonly config: ToolPolicyConfig) {
|
|
23
|
-
this.normalizedAllowedTools = new Set(
|
|
24
|
-
config.allowedTools.flatMap((name) => normalizeAllowedToolName(name))
|
|
25
|
-
);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
filterTools(tools: ToolPolicyMeta[]): ToolPolicyMeta[] {
|
|
29
|
-
return tools.filter((tool) => this.isToolEnabled(tool));
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
assertCanExecute(tool: ToolPolicyMeta): void {
|
|
33
|
-
if (!this.isToolEnabled(tool)) {
|
|
34
|
-
throw new Error(`Tool '${tool.name}' is disabled by policy`);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
isToolEnabled(tool: ToolPolicyMeta): boolean {
|
|
39
|
-
if (this.config.readOnlyMode && tool.mutating) {
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (!this.isFeatureEnabled(tool)) {
|
|
44
|
-
return false;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
if (this.normalizedAllowedTools.size > 0 && !this.normalizedAllowedTools.has(tool.name)) {
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
if (this.config.deniedToolsRegex && this.config.deniedToolsRegex.test(tool.name)) {
|
|
52
|
-
return false;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
return true;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
private isFeatureEnabled(tool: ToolPolicyMeta): boolean {
|
|
59
|
-
if (!tool.requiresFeature) {
|
|
60
|
-
return true;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return this.config.enabledFeatures[tool.requiresFeature];
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function normalizeAllowedToolName(name: string): string[] {
|
|
68
|
-
const value = name.trim();
|
|
69
|
-
if (value.length === 0) {
|
|
70
|
-
return [];
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
if (value.startsWith("gitlab_")) {
|
|
74
|
-
return [value];
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return [value, `gitlab_${value}`];
|
|
78
|
-
}
|