@nimbus-dev/connectors 0.4.0 → 0.6.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.
@@ -43,15 +43,59 @@ const listArgs = z.object({
43
43
  calendar: z.string().min(1).optional(),
44
44
  });
45
45
 
46
+ /** A C0 control (CR, LF and TAB included), DEL, or a C1 control. */
47
+ function isControlCode(code: number): boolean {
48
+ return code <= 0x1f || (code >= 0x7f && code <= 0x9f);
49
+ }
50
+
51
+ /**
52
+ * Whether `value` carries a control character anywhere. Event text reaches iCalendar content
53
+ * lines, where CR or LF ends a line and starts a new property; the SDK's escaping turns CRLF or LF
54
+ * into an escaped newline but leaves a bare CR. So the input is REFUSED here rather than relying
55
+ * on (or adding) an escape. Nothing is stripped.
56
+ */
57
+ export function hasControlCharacter(value: string): boolean {
58
+ for (let i = 0; i < value.length; i++) {
59
+ if (isControlCode(value.charCodeAt(i))) return true;
60
+ }
61
+ return false;
62
+ }
63
+
64
+ /** Whether `value` carries any whitespace (Unicode `\s`) or control character. */
65
+ export function hasWhitespaceOrControl(value: string): boolean {
66
+ return /\s/u.test(value) || hasControlCharacter(value);
67
+ }
68
+
69
+ export const ICLOUD_TEXT_REFUSAL = "must not contain a line break or other control character";
70
+ export const ICLOUD_TIME_REFUSAL =
71
+ "must be a UTC time in the form YYYYMMDDTHHMMSSZ; all-day and floating times are not supported";
72
+ export const ICLOUD_UID_REFUSAL =
73
+ "must be 1 to 200 characters, each a letter, a digit, '.', '_', '@' or '-'";
74
+ export const ICLOUD_ATTENDEE_REFUSAL =
75
+ "each attendee must be one email address, with no whitespace or control character";
76
+
77
+ /** `YYYYMMDDTHHMMSSZ`: a UTC date-time and nothing else (owner ruling OR-B4-6). */
78
+ const UTC_DATE_TIME = /^\d{8}T\d{6}Z$/;
79
+ const EVENT_UID = /^[A-Za-z0-9._@-]{1,200}$/;
80
+
81
+ /** A free-text event field: refused, never cleaned, when it carries a control character. */
82
+ function eventText(): z.ZodString {
83
+ return z.string().refine((s) => !hasControlCharacter(s), ICLOUD_TEXT_REFUSAL);
84
+ }
85
+
46
86
  const createArgs = z.object({
47
- calendar: z.string().min(1).optional(),
48
- summary: z.string().min(1).max(1000),
49
- start: z.string().min(1),
50
- end: z.string().min(1),
51
- description: z.string().max(10_000).optional(),
52
- location: z.string().max(500).optional(),
53
- attendees: z.array(z.email()).optional(),
54
- uid: z.string().min(1).optional(),
87
+ calendar: eventText().min(1).optional(),
88
+ summary: eventText().min(1).max(1000),
89
+ start: z.string().regex(UTC_DATE_TIME, ICLOUD_TIME_REFUSAL),
90
+ end: z.string().regex(UTC_DATE_TIME, ICLOUD_TIME_REFUSAL),
91
+ description: eventText().max(10_000).optional(),
92
+ location: eventText().max(500).optional(),
93
+ // z.email() already refuses whitespace and controls today; the explicit check keeps that a
94
+ // stated property of THIS schema, not of zod's current email pattern.
95
+ attendees: z
96
+ .array(z.email().refine((s) => !hasWhitespaceOrControl(s), ICLOUD_ATTENDEE_REFUSAL))
97
+ .optional(),
98
+ uid: z.string().regex(EVENT_UID, ICLOUD_UID_REFUSAL).optional(),
55
99
  });
56
100
 
57
101
  const deleteArgs = z.object({
@@ -199,7 +243,7 @@ export function registerAppleCalendarTools(
199
243
  recoverable: true,
200
244
  scopeTargetOf: (p) => ({ kind: "calendar", value: p.calendar ?? "default" }),
201
245
  },
202
- "Create a new event in iCloud Calendar via CalDAV PUT.",
246
+ "Create a new event in iCloud Calendar via CalDAV PUT. start and end are UTC, in the form YYYYMMDDTHHMMSSZ (all-day and floating times are refused). summary, description, location and calendar may not contain a line break or other control character; uid, when given, is 1-200 of A-Z a-z 0-9 . _ @ -.",
203
247
  createArgs,
204
248
  async (parsedData) => {
205
249
  const parsed = { success: true as const, data: parsedData };
@@ -71,6 +71,37 @@ function readPageSnapshot(res: { ok: boolean; status: number; text: string }): P
71
71
  };
72
72
  }
73
73
 
74
+ /** One entry of `confluence_page_get`'s `ancestors`: what a caller needs, not the whole page. */
75
+ export interface PageAncestor {
76
+ readonly id: string | null;
77
+ readonly title: string | null;
78
+ }
79
+
80
+ /**
81
+ * Narrow the `ancestors` Confluence expands on a page to `{ id, title }` per entry, in Confluence's
82
+ * order (the space's top-level page first, the page's direct parent last). An ancestor is a whole
83
+ * content object (links, extensions, status); a caller deciding where a page sits needs only its
84
+ * id, and the title is free. An entry without a string id keeps its place with `id: null` rather
85
+ * than being dropped, so the chain is never shorter than Confluence said. A reply with no
86
+ * `ancestors` array is returned as it came: absent stays absent, never an invented `[]`.
87
+ */
88
+ export function withAncestorSummaries(page: unknown): unknown {
89
+ if (page === null || typeof page !== "object" || Array.isArray(page)) return page;
90
+ const obj = page as Record<string, unknown>;
91
+ const ancestors = obj["ancestors"];
92
+ if (!Array.isArray(ancestors)) return page;
93
+ const summaries: PageAncestor[] = ancestors.map((a: unknown) => {
94
+ const entry = a !== null && typeof a === "object" ? (a as Record<string, unknown>) : {};
95
+ const id = entry["id"];
96
+ const title = entry["title"];
97
+ return {
98
+ id: typeof id === "string" ? id : null,
99
+ title: typeof title === "string" ? title : null,
100
+ };
101
+ });
102
+ return { ...obj, ancestors: summaries };
103
+ }
104
+
74
105
  /** Tool names exposed by this connector — for contract/introspection tests. */
75
106
  export const CONFLUENCE_TOOL_NAMES = [
76
107
  "confluence_space_list",
@@ -150,14 +181,23 @@ export function registerConfluenceTools(
150
181
 
151
182
  reg(
152
183
  "confluence_page_get",
153
- "Get a Confluence page with body.storage (GET /content/{id}).",
184
+ "Get a Confluence page with body.storage (GET /content/{id}). ancestors lists the pages above it as { id, title }, the top-level page first and the direct parent last.",
154
185
  confluencePageIdSchema,
155
186
  async (parsed) => {
156
187
  const id = encodeURIComponent(parsed.pageId);
157
188
  const res = await confFetch(
158
- `/content/${id}?expand=body.storage,version,history.lastUpdated,space`,
189
+ `/content/${id}?expand=body.storage,version,history.lastUpdated,space,ancestors`,
159
190
  );
160
- return mcpJsonResultFromTextIfOk("Confluence", res);
191
+ if (!res.ok) {
192
+ throw new Error(`Confluence ${String(res.status)}: ${res.text.slice(0, 400)}`);
193
+ }
194
+ let page: unknown;
195
+ try {
196
+ page = JSON.parse(res.text) as unknown;
197
+ } catch {
198
+ throw new Error("Confluence: invalid JSON response");
199
+ }
200
+ return mcpJsonResult(withAncestorSummaries(page));
161
201
  },
162
202
  );
163
203
 
@@ -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 (read-only). 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. The `flagsmith.flag.toggle` write tool is a deferred Phase 8 follow-up.",
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 { runReadOnlyMcpConnector } from "../../../shared/run-read-only-mcp-connector.ts";
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
- await runReadOnlyMcpConnector("nimbus-flagsmith", (reg) => {
5
- registerFlagsmithTools(reg);
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 { createJsonGetter, envAuthHeaders, optionalEnv } from "../../../shared/env-json-api.ts";
3
- import { mcpJsonResult as jsonResult } from "../../../shared/mcp-tool-kit.ts";
4
- import type { ZodToolRegistrar } from "../../../shared/run-read-only-mcp-connector.ts";
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
- const fsGet = createJsonGetter({
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: envAuthHeaders({ env: "FLAGSMITH_TOKEN", scheme: "Token" }),
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
- export function registerFlagsmithTools(reg: ZodToolRegistrar): void {
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 { ghFetch, ghQueryPath, ghRepoPath, setGhPaging } from "../../../shared/github-rest.ts";
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: (p) => ({ kind: "repo", value: `${p.owner}/${p.repo}` }) },
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().min(1),
95
- repo: z.string().min(1),
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().min(1),
58
- repo: z.string().min(1),
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({