@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.
- package/connectors/apple/src/calendar-tools.ts +53 -9
- package/connectors/confluence/src/tools.ts +43 -3
- 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/obsidian/nimbus.extension.json +1 -1
- package/connectors/obsidian/src/tools.ts +158 -10
- package/connectors/pagerduty/src/tools.ts +122 -27
- 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
|
@@ -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:
|
|
48
|
-
summary:
|
|
49
|
-
start: z.string().
|
|
50
|
-
end: z.string().
|
|
51
|
-
description:
|
|
52
|
-
location:
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|
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({
|