@nimbus-dev/connectors 0.5.0 → 0.7.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/connectors/flagsmith/nimbus.extension.json +2 -2
- package/connectors/flagsmith/src/server.ts +12 -4
- package/connectors/flagsmith/src/tools.ts +147 -7
- package/connectors/github/src/tools.ts +134 -4
- package/connectors/github-actions/src/tools.ts +3 -2
- package/connectors/gitlab/src/tools.ts +186 -19
- package/connectors/google-drive/src/tools.ts +10 -2
- package/connectors/jira/nimbus.extension.json +1 -1
- package/connectors/jira/src/tools.ts +254 -1
- package/connectors/launchdarkly/nimbus.extension.json +2 -2
- package/connectors/launchdarkly/src/flag-state.ts +178 -0
- package/connectors/launchdarkly/src/server.ts +12 -4
- package/connectors/launchdarkly/src/tools.ts +184 -10
- package/connectors/pagerduty/src/tools.ts +122 -27
- package/connectors/slack/src/tools.ts +56 -5
- package/connectors/teams/src/bot-send.ts +10 -2
- package/connectors/teams/src/tools.ts +18 -1
- package/package.json +1 -1
- package/shared/env-json-api.ts +57 -15
- package/shared/github-rest.ts +128 -5
- package/shared/guarded-fetch.ts +135 -0
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
"id": "com.nimbus.flagsmith",
|
|
3
3
|
"displayName": "Flagsmith",
|
|
4
4
|
"version": "0.1.0",
|
|
5
|
-
"description": "Flagsmith feature-flag connector
|
|
5
|
+
"description": "Flagsmith feature-flag connector. Surfaces feature-flag definitions across all projects — name, type, default-enabled, initial value, description, tags, archived state, owner count, and timestamps — into the local index as `flagsmith:feature_flag` items, useful for incident correlation (\"did this flag exist when the alert fired?\"). Admin-API-token auth (`Authorization: Token <token>`). Regional / self-hosted instances override `flagsmith.api_base` (default https://api.flagsmith.com); the SaaS API host (api.flagsmith.com) is the only one in the static sandbox manifest today. v1 indexes flag definitions only — per-environment on/off state + segments are a deferred follow-up. Write tool (needs owner approval): `flagsmith.feature.state.set` enables or disables a feature, optionally with a typed value, in ONE environment, through Flagsmith's experimental `__future__` endpoint; Flagsmith refuses it while change requests are enabled.",
|
|
6
6
|
"author": "Nimbus",
|
|
7
7
|
"entrypoint": "dist/server.js",
|
|
8
8
|
"runtime": "bun",
|
|
9
9
|
"permissions": {
|
|
10
10
|
"network": ["api.flagsmith.com"]
|
|
11
11
|
},
|
|
12
|
-
"hitlRequired": [],
|
|
12
|
+
"hitlRequired": ["write"],
|
|
13
13
|
"syncInterval": 600,
|
|
14
14
|
"minNimbusVersion": "0.2.0"
|
|
15
15
|
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { CONSENT_SERVER_CAPABILITIES } from "../../../shared/consent-kit.ts";
|
|
2
4
|
import { registerFlagsmithTools } from "./tools.ts";
|
|
3
5
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
6
|
+
const server = new McpServer(
|
|
7
|
+
{ name: "nimbus-flagsmith", version: "0.1.0" },
|
|
8
|
+
{ capabilities: CONSENT_SERVER_CAPABILITIES },
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
registerFlagsmithTools(server);
|
|
12
|
+
|
|
13
|
+
const transport = new StdioServerTransport();
|
|
14
|
+
await server.connect(transport);
|
|
@@ -1,17 +1,52 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
2
|
+
import { type ConsentServer, createWriteToolRegistrar } from "../../../shared/consent-kit.ts";
|
|
3
|
+
import {
|
|
4
|
+
createJsonGetter,
|
|
5
|
+
createJsonSender,
|
|
6
|
+
envAuthHeaders,
|
|
7
|
+
type JsonApiConfig,
|
|
8
|
+
optionalEnv,
|
|
9
|
+
} from "../../../shared/env-json-api.ts";
|
|
10
|
+
import {
|
|
11
|
+
createRegisterSimpleTool,
|
|
12
|
+
createZodToolRegistrar,
|
|
13
|
+
mcpJsonResult as jsonResult,
|
|
14
|
+
} from "../../../shared/mcp-tool-kit.ts";
|
|
5
15
|
import { filterFlagsmithFeatures } from "./search-filter.ts";
|
|
6
16
|
|
|
7
17
|
const DEFAULT_API_BASE = "https://api.flagsmith.com";
|
|
8
18
|
|
|
9
|
-
|
|
19
|
+
/**
|
|
20
|
+
* `Authorization: Token <FLAGSMITH_TOKEN>` for reads and writes alike. Flagsmith's swagger lists
|
|
21
|
+
* this scheme ("tokenAuth", prefix "Token") on the `__future__` operations too, beside the
|
|
22
|
+
* organisation "Master API Key" scheme; the docs page for those operations shows `Api-Key`, which
|
|
23
|
+
* is that second scheme. One factory, so a switch is one line.
|
|
24
|
+
*/
|
|
25
|
+
const flagsmithHeaders = envAuthHeaders({ env: "FLAGSMITH_TOKEN", scheme: "Token" });
|
|
26
|
+
|
|
27
|
+
const apiV1: JsonApiConfig = {
|
|
10
28
|
// `optionalEnv`, not `optionalBaseUrl`: a trailing slash on the override is left as given.
|
|
11
29
|
base: () => `${optionalEnv("FLAGSMITH_API_BASE", DEFAULT_API_BASE)}/api/v1`,
|
|
12
30
|
label: "Flagsmith",
|
|
13
|
-
headers:
|
|
14
|
-
}
|
|
31
|
+
headers: flagsmithHeaders,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The environment-level feature-state endpoints. EXPERIMENTAL ("may change without notice",
|
|
36
|
+
* https://docs.flagsmith.com/managing-flags/updating-flags). Why not the v1 route: Flagsmith's own
|
|
37
|
+
* repository calls `/api/v1/environments/{key}/featurestates/` "deprecated" (PR 8732, "the legacy
|
|
38
|
+
* feature state APIs"), the docs site documents only the `__future__` pair, and the v1 write needs
|
|
39
|
+
* a feature-state id looked up first. `__future__` takes the feature id directly. Isolated here so
|
|
40
|
+
* a change of path is one edit.
|
|
41
|
+
*/
|
|
42
|
+
const apiFuture: JsonApiConfig = {
|
|
43
|
+
...apiV1,
|
|
44
|
+
base: () => `${optionalEnv("FLAGSMITH_API_BASE", DEFAULT_API_BASE)}/api/__future__`,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const fsGet = createJsonGetter(apiV1);
|
|
48
|
+
const fsFutureGet = createJsonGetter(apiFuture);
|
|
49
|
+
const fsFutureSend = createJsonSender(apiFuture);
|
|
15
50
|
|
|
16
51
|
function featuresFromPage(root: unknown): unknown[] {
|
|
17
52
|
if (Array.isArray(root)) {
|
|
@@ -26,9 +61,65 @@ export const FLAGSMITH_TOOL_NAMES = [
|
|
|
26
61
|
"flagsmith_list",
|
|
27
62
|
"flagsmith_get",
|
|
28
63
|
"flagsmith_search",
|
|
64
|
+
"flagsmith_feature_state_get",
|
|
65
|
+
"flagsmith_feature_state_set",
|
|
29
66
|
] as const;
|
|
30
67
|
|
|
31
|
-
|
|
68
|
+
/** Segment overrides returned by a state read; the rest are counted, not shown (size bound). */
|
|
69
|
+
const MAX_SEGMENT_OVERRIDES = 50;
|
|
70
|
+
|
|
71
|
+
const featureStateArgs = z.object({
|
|
72
|
+
environmentKey: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/, "letters, digits, '_' and '-' only"),
|
|
73
|
+
featureId: z.number().int().min(1),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
type FeatureStateArgs = z.infer<typeof featureStateArgs>;
|
|
77
|
+
|
|
78
|
+
function statePath(p: FeatureStateArgs): string {
|
|
79
|
+
return `/environments/${encodeURIComponent(p.environmentKey)}/features/${String(p.featureId)}/`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `{ environment_default, segment_overrides }` with the overrides capped and the cap disclosed. */
|
|
83
|
+
async function readFeatureState(p: FeatureStateArgs): Promise<Record<string, unknown>> {
|
|
84
|
+
const raw = (await fsFutureGet(statePath(p))) as {
|
|
85
|
+
environment_default?: unknown;
|
|
86
|
+
segment_overrides?: unknown;
|
|
87
|
+
} | null;
|
|
88
|
+
const overrides = Array.isArray(raw?.segment_overrides) ? raw.segment_overrides : [];
|
|
89
|
+
return {
|
|
90
|
+
environment_default: raw?.environment_default,
|
|
91
|
+
segment_overrides: overrides.slice(0, MAX_SEGMENT_OVERRIDES),
|
|
92
|
+
...(overrides.length > MAX_SEGMENT_OVERRIDES
|
|
93
|
+
? { segmentOverridesNotShown: overrides.length - MAX_SEGMENT_OVERRIDES }
|
|
94
|
+
: {}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const valueSchema = z.object({
|
|
99
|
+
type: z.enum(["string", "integer", "boolean"]),
|
|
100
|
+
value: z.string().max(20_000),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/** The typed value must parse as its type; checked in the handler, before any request. */
|
|
104
|
+
function assertValueMatchesType(v: z.infer<typeof valueSchema>): void {
|
|
105
|
+
if (v.type === "integer" && !/^-?\d+$/.test(v.value)) {
|
|
106
|
+
throw new Error(`value ${JSON.stringify(v.value)} is not an integer; nothing was sent`);
|
|
107
|
+
}
|
|
108
|
+
if (v.type === "boolean" && v.value !== "true" && v.value !== "false") {
|
|
109
|
+
throw new Error(`value ${JSON.stringify(v.value)} is not true or false; nothing was sent`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function registerFlagsmithTools(
|
|
114
|
+
server: ConsentServer & { tool: (...args: never) => unknown },
|
|
115
|
+
): void {
|
|
116
|
+
const reg = createZodToolRegistrar(createRegisterSimpleTool(server));
|
|
117
|
+
const registerWriteTool = createWriteToolRegistrar(server, {
|
|
118
|
+
connector: "flagsmith",
|
|
119
|
+
scopeEnv: "NIMBUS_MCP_FLAGSMITH_WRITE_SCOPE",
|
|
120
|
+
scopeKinds: ["flag"],
|
|
121
|
+
});
|
|
122
|
+
|
|
32
123
|
reg(
|
|
33
124
|
"flagsmith_list",
|
|
34
125
|
"List Flagsmith projects, or feature flags for a project. Without `projectId`, returns the account's projects (`/projects/`). With `projectId`, returns that project's feature flags (`/projects/{projectId}/features/`), capped at `limit` (default 100).",
|
|
@@ -92,4 +183,53 @@ export function registerFlagsmithTools(reg: ZodToolRegistrar): void {
|
|
|
92
183
|
return jsonResult({ matches });
|
|
93
184
|
},
|
|
94
185
|
);
|
|
186
|
+
|
|
187
|
+
reg(
|
|
188
|
+
"flagsmith_feature_state_get",
|
|
189
|
+
"Read one feature's state in ONE environment (`GET /api/__future__/environments/{environmentKey}/features/{featureId}/`, an EXPERIMENTAL Flagsmith endpoint): `environment_default` (enabled, typed value, variants) and `segment_overrides` (capped at 50, the remainder counted in `segmentOverridesNotShown`). Identity overrides are not covered.",
|
|
190
|
+
featureStateArgs,
|
|
191
|
+
async (p) => jsonResult(await readFeatureState(p)),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
registerWriteTool(
|
|
195
|
+
"flagsmith_feature_state_set",
|
|
196
|
+
{
|
|
197
|
+
mutates: "flagsmith.feature.state.set",
|
|
198
|
+
// The previous state is recorded in the audit pre-state; this tool does not restore it.
|
|
199
|
+
recoverable: false,
|
|
200
|
+
capturePreState: readFeatureState,
|
|
201
|
+
scopeTargetOf: (p) => ({ kind: "flag", value: `${p.environmentKey}/${String(p.featureId)}` }),
|
|
202
|
+
},
|
|
203
|
+
"Enable or disable a feature in ONE environment, optionally setting its typed value (`PATCH /api/__future__/environments/{environmentKey}/features/{featureId}/`, an EXPERIMENTAL Flagsmith endpoint that may change without notice). Omitting `value` is intended to leave the value alone (PATCH merge semantics are unverified against a live server); segment and identity overrides are untouched. Flagsmith refuses it while change requests are enabled for the environment, with its own message. The result carries `confirmed`: true only when the reply shows the requested `enabled` and, when `value` was given, that value; false means the server accepted the write but its reply does not show the new state.",
|
|
204
|
+
featureStateArgs.extend({ enabled: z.boolean(), value: valueSchema.optional() }),
|
|
205
|
+
async (p) => {
|
|
206
|
+
// Validated here, not with `.refine`: a refined schema loses `.shape` (the registrar needs it).
|
|
207
|
+
if (p.value !== undefined) assertValueMatchesType(p.value);
|
|
208
|
+
const response = (await fsFutureSend("PATCH", statePath(p), {
|
|
209
|
+
environment_default: {
|
|
210
|
+
enabled: p.enabled,
|
|
211
|
+
...(p.value === undefined ? {} : { value: p.value }),
|
|
212
|
+
},
|
|
213
|
+
})) as { environment_default?: { enabled?: unknown; value?: unknown } } | null;
|
|
214
|
+
const echoed = response?.environment_default;
|
|
215
|
+
// The value comes back as `{type, value}` like it is sent; its inner value may be typed or a
|
|
216
|
+
// string, so the string forms are compared, and a type the reply names must be the one sent.
|
|
217
|
+
const shown = echoed?.value as { type?: unknown; value?: unknown } | undefined;
|
|
218
|
+
const valueShown =
|
|
219
|
+
p.value === undefined ||
|
|
220
|
+
(shown !== undefined &&
|
|
221
|
+
shown !== null &&
|
|
222
|
+
String(shown.value) === p.value.value &&
|
|
223
|
+
(shown.type === undefined || shown.type === p.value.type));
|
|
224
|
+
return jsonResult({
|
|
225
|
+
ok: true,
|
|
226
|
+
environmentKey: p.environmentKey,
|
|
227
|
+
featureId: p.featureId,
|
|
228
|
+
// true only when the reply shows the state that was asked for; false means the server
|
|
229
|
+
// accepted the write but its reply does not show the new state.
|
|
230
|
+
confirmed: echoed?.enabled === p.enabled && valueShown,
|
|
231
|
+
enabled: echoed?.enabled,
|
|
232
|
+
});
|
|
233
|
+
},
|
|
234
|
+
);
|
|
95
235
|
}
|
|
@@ -4,7 +4,15 @@ import {
|
|
|
4
4
|
createWriteToolRegistrar,
|
|
5
5
|
type WriteToolConfig,
|
|
6
6
|
} from "../../../shared/consent-kit.ts";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
ghFetch,
|
|
9
|
+
ghQueryPath,
|
|
10
|
+
ghRepoPath,
|
|
11
|
+
isSafeGhSlug,
|
|
12
|
+
isSafeGitRef,
|
|
13
|
+
resolveCommitSha,
|
|
14
|
+
setGhPaging,
|
|
15
|
+
} from "../../../shared/github-rest.ts";
|
|
8
16
|
import {
|
|
9
17
|
createRegisterSimpleTool,
|
|
10
18
|
createZodToolRegistrar,
|
|
@@ -35,6 +43,10 @@ export const GITHUB_TOOL_NAMES = [
|
|
|
35
43
|
"github_ci_runs",
|
|
36
44
|
"github_ci_run_get",
|
|
37
45
|
"github_branch_delete",
|
|
46
|
+
"github_ref_get",
|
|
47
|
+
"github_branch_create",
|
|
48
|
+
"github_pr_create",
|
|
49
|
+
"github_pr_update",
|
|
38
50
|
"github_tag_create",
|
|
39
51
|
"github_commit_push",
|
|
40
52
|
] as const;
|
|
@@ -61,6 +73,11 @@ export function registerGithubTools(
|
|
|
61
73
|
const registerGithubTool = makeRestToolRegistrar({ registrar: reg, ...githubRest });
|
|
62
74
|
const registerRestWriteTool = makeRestWriteToolRegistrar({ registerWriteTool, ...githubRest });
|
|
63
75
|
|
|
76
|
+
const repoScope = (p: { owner: string; repo: string }): { kind: string; value: string } => ({
|
|
77
|
+
kind: "repo",
|
|
78
|
+
value: `${p.owner}/${p.repo}`,
|
|
79
|
+
});
|
|
80
|
+
|
|
64
81
|
/**
|
|
65
82
|
* The write-tool equivalent of `registerGithubTool`, routed through the write registrar.
|
|
66
83
|
* `scopeTargetOf` is supplied here rather than per tool — every GitHub mutation is scoped to one
|
|
@@ -76,7 +93,7 @@ export function registerGithubTools(
|
|
|
76
93
|
): void {
|
|
77
94
|
registerRestWriteTool(
|
|
78
95
|
name,
|
|
79
|
-
{ ...cfg, scopeTargetOf:
|
|
96
|
+
{ ...cfg, scopeTargetOf: repoScope },
|
|
80
97
|
description,
|
|
81
98
|
schema,
|
|
82
99
|
buildPath,
|
|
@@ -91,8 +108,8 @@ export function registerGithubTools(
|
|
|
91
108
|
});
|
|
92
109
|
|
|
93
110
|
const repoSlugArgs = z.object({
|
|
94
|
-
owner: z.string().
|
|
95
|
-
repo: z.string().
|
|
111
|
+
owner: z.string().refine(isSafeGhSlug, "not a valid GitHub owner name"),
|
|
112
|
+
repo: z.string().refine(isSafeGhSlug, "not a valid GitHub repository name"),
|
|
96
113
|
});
|
|
97
114
|
|
|
98
115
|
const githubRepoListSchema = z.object({
|
|
@@ -327,6 +344,119 @@ export function registerGithubTools(
|
|
|
327
344
|
},
|
|
328
345
|
);
|
|
329
346
|
|
|
347
|
+
/** A ref the model names reaches a URL path, so every one passes `isSafeGitRef` at the schema. */
|
|
348
|
+
const gitRefSchema = z.string().min(1).max(255).refine(isSafeGitRef, "not a safe git ref name");
|
|
349
|
+
|
|
350
|
+
const githubRefGetSchema = repoSlugArgs.extend({ ref: gitRefSchema });
|
|
351
|
+
|
|
352
|
+
reg(
|
|
353
|
+
"github_ref_get",
|
|
354
|
+
"Resolve a branch, tag or commit to its commit SHA (a bare name is tried as a branch, then a tag; an annotated tag resolves to its commit). Returns {ref, kind: branch|tag|sha, sha}.",
|
|
355
|
+
githubRefGetSchema,
|
|
356
|
+
async (parsed) => {
|
|
357
|
+
const token = requireProcessEnv("GITHUB_PAT");
|
|
358
|
+
const { sha, kind } = await resolveCommitSha(token, parsed.owner, parsed.repo, parsed.ref);
|
|
359
|
+
return jsonResult({ ref: parsed.ref, kind, sha });
|
|
360
|
+
},
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
const githubBranchCreateSchema = repoSlugArgs.extend({
|
|
364
|
+
branch: gitRefSchema,
|
|
365
|
+
fromRef: gitRefSchema,
|
|
366
|
+
expectedSha: z
|
|
367
|
+
.string()
|
|
368
|
+
.regex(/^[0-9a-f]{40}$/i)
|
|
369
|
+
.optional(),
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
registerWriteTool(
|
|
373
|
+
"github_branch_create",
|
|
374
|
+
// A new ref is undone by github_branch_delete, so there is nothing to capture first.
|
|
375
|
+
{ mutates: "github.branch.create", recoverable: true, scopeTargetOf: repoScope },
|
|
376
|
+
"Create a branch from a branch, tag or commit (fromRef). If expectedSha is given and fromRef now resolves to a different commit, nothing is created.",
|
|
377
|
+
githubBranchCreateSchema,
|
|
378
|
+
async (parsed) => {
|
|
379
|
+
const token = requireProcessEnv("GITHUB_PAT");
|
|
380
|
+
const { sha } = await resolveCommitSha(token, parsed.owner, parsed.repo, parsed.fromRef);
|
|
381
|
+
if (parsed.expectedSha !== undefined && parsed.expectedSha.toLowerCase() !== sha) {
|
|
382
|
+
throw new Error(`fromRef resolves to ${sha}, not the approved ${parsed.expectedSha}`);
|
|
383
|
+
}
|
|
384
|
+
const ref = `refs/heads/${parsed.branch}`;
|
|
385
|
+
const res = await ghFetch(
|
|
386
|
+
token,
|
|
387
|
+
`${ghRepoPath(parsed.owner, parsed.repo)}/git/refs`,
|
|
388
|
+
jsonInit("POST", { ref, sha }),
|
|
389
|
+
);
|
|
390
|
+
if (!res.ok) {
|
|
391
|
+
throw new Error(`GitHub ${String(res.status)}: ${res.text.slice(0, 300)}`);
|
|
392
|
+
}
|
|
393
|
+
return jsonResult({ ok: true, ref, sha, fromRef: parsed.fromRef });
|
|
394
|
+
},
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
const githubPrCreateSchema = repoSlugArgs.extend({
|
|
398
|
+
head: z.string().min(1).max(255),
|
|
399
|
+
base: z.string().min(1).max(255),
|
|
400
|
+
title: z.string().min(1).max(256),
|
|
401
|
+
body: z.string().max(65_000).optional(),
|
|
402
|
+
draft: z.boolean().optional(),
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
registerGithubWriteTool(
|
|
406
|
+
"github_pr_create",
|
|
407
|
+
{ mutates: "github.pr.create", recoverable: true },
|
|
408
|
+
"Open a pull request from head (a branch, or user:branch for a fork) into base. Title and body are sent exactly as given.",
|
|
409
|
+
githubPrCreateSchema,
|
|
410
|
+
(parsed) => `${ghRepoPath(parsed.owner, parsed.repo)}/pulls`,
|
|
411
|
+
(parsed) =>
|
|
412
|
+
jsonInit("POST", {
|
|
413
|
+
head: parsed.head,
|
|
414
|
+
base: parsed.base,
|
|
415
|
+
title: parsed.title,
|
|
416
|
+
...(parsed.body === undefined ? {} : { body: parsed.body }),
|
|
417
|
+
...(parsed.draft === undefined ? {} : { draft: parsed.draft }),
|
|
418
|
+
}),
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
// A plain object, not a `.refine()`d one: the registrar passes `.shape` as the MCP inputSchema and a
|
|
422
|
+
// refined object has none. The at-least-one check is the handler's.
|
|
423
|
+
const githubPrUpdateSchema = githubPrNumberSchema.extend({
|
|
424
|
+
title: z.string().min(1).max(256).optional(),
|
|
425
|
+
body: z.string().max(65_000).optional(),
|
|
426
|
+
base: z.string().min(1).max(255).optional(),
|
|
427
|
+
});
|
|
428
|
+
const prPath = (parsed: { owner: string; repo: string; pullNumber: number }): string =>
|
|
429
|
+
`${ghRepoPath(parsed.owner, parsed.repo)}/pulls/${String(parsed.pullNumber)}`;
|
|
430
|
+
|
|
431
|
+
registerGithubWriteTool(
|
|
432
|
+
"github_pr_update",
|
|
433
|
+
{
|
|
434
|
+
mutates: "github.pr.update",
|
|
435
|
+
// An edit overwrites text; its inverse needs the old text, so capture it first.
|
|
436
|
+
recoverable: false,
|
|
437
|
+
capturePreState: async (parsed) => {
|
|
438
|
+
const token = requireProcessEnv("GITHUB_PAT");
|
|
439
|
+
const res = await ghFetch(token, prPath(parsed));
|
|
440
|
+
const pr = res.json as { title?: unknown; body?: unknown; base?: { ref?: unknown } } | null;
|
|
441
|
+
if (!res.ok || pr === null) return { resolved: false, status: res.status };
|
|
442
|
+
return { title: pr.title, body: pr.body, base: pr.base?.ref };
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
"Edit a pull request's title, body or base branch (at least one). It never opens, closes or merges it.",
|
|
446
|
+
githubPrUpdateSchema,
|
|
447
|
+
prPath,
|
|
448
|
+
(parsed) => {
|
|
449
|
+
if (parsed.title === undefined && parsed.body === undefined && parsed.base === undefined) {
|
|
450
|
+
throw new Error("github_pr_update needs at least one of title, body, base");
|
|
451
|
+
}
|
|
452
|
+
return jsonInit("PATCH", {
|
|
453
|
+
...(parsed.title === undefined ? {} : { title: parsed.title }),
|
|
454
|
+
...(parsed.body === undefined ? {} : { body: parsed.body }),
|
|
455
|
+
...(parsed.base === undefined ? {} : { base: parsed.base }),
|
|
456
|
+
});
|
|
457
|
+
},
|
|
458
|
+
);
|
|
459
|
+
|
|
330
460
|
const githubTagCreateSchema = repoSlugArgs.extend({
|
|
331
461
|
tag: z.string().min(1).max(255),
|
|
332
462
|
sha: z.string().min(7).max(40),
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
ghFetch,
|
|
9
9
|
ghQueryPath,
|
|
10
10
|
ghRepoPath,
|
|
11
|
+
isSafeGhSlug,
|
|
11
12
|
setGhPaging,
|
|
12
13
|
} from "../../../shared/github-rest.ts";
|
|
13
14
|
import {
|
|
@@ -54,8 +55,8 @@ export function registerGithubActionsTools(
|
|
|
54
55
|
});
|
|
55
56
|
|
|
56
57
|
const repoSlugArgs = z.object({
|
|
57
|
-
owner: z.string().
|
|
58
|
-
repo: z.string().
|
|
58
|
+
owner: z.string().refine(isSafeGhSlug, "not a valid GitHub owner name"),
|
|
59
|
+
repo: z.string().refine(isSafeGhSlug, "not a valid GitHub repository name"),
|
|
59
60
|
});
|
|
60
61
|
|
|
61
62
|
const runListSchema = repoSlugArgs.extend({
|