@intentius/chant-lexicon-github 0.2.0 → 0.3.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "9bfb1e2f89ab306d4369dbd5df0a9cb0fc22cc8d282f37a1c6195ce86b812410",
4
+ "manifest.json": "0fa34c5776ecd073eddf047bf545d4e7eec1b2bef82bda8e8b9a937dafefdbe3",
5
5
  "meta.json": "798c5f5f37b0174756d2451299acfdc95c014f11d06d32b9153bbdfa9f3a580c",
6
6
  "types/index.d.ts": "39c0791f0e58025bcef82e0f7a92240508f899fc2771d5dd51b2369b4d330f13",
7
7
  "rules/deprecated-action-version.ts": "d41e6e532ab7f623af1bee4ac5279fcb2baada7defa1c5d022a5bc71983e8797",
@@ -67,5 +67,5 @@
67
67
  "skills/chant-github-patterns.md": "bb3abef289a8fdfcf07d6bb2d7289dcb2f38bc0cb0321ea320b78b45a6f548c0",
68
68
  "skills/chant-github-security.md": "aab111cb0871cad30281ce48d7da23663689619351029219e2be019a1a61e394"
69
69
  },
70
- "composite": "c655b1bc501ac68358351aa669e0eeca83ba58bf483d5503949bb1dad5410586"
70
+ "composite": "b9406b7d04e47eb1ac9b2562be29fad73787acf1a3d610c2920fd96d874f85f9"
71
71
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitHub",
6
6
  "intrinsics": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-github",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "GitHub Actions lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -25,7 +25,7 @@ npm install --save-dev @intentius/chant-lexicon-github
25
25
 
26
26
  {{file:docs-snippets/src/quickstart.ts}}
27
27
 
28
- The lexicon provides **2 resources** (Workflow, Job), **13 composites** (Checkout, SetupNode, SetupGo, SetupPython, CacheAction, UploadArtifact, DownloadArtifact, NodeCI, NodePipeline, PythonCI, DockerBuild, DeployEnvironment, GoCI) + **3 presets** (BunPipeline, PnpmPipeline, YarnPipeline), a typed **Expression** system with 24 GitHub and 5 Runner context variables, and **13 lint rules** + **6 post-synth checks**.
28
+ The lexicon provides **3 resources** (Workflow, Job, Dependabot config), **14 composites** (Checkout, SetupNode, SetupGo, SetupPython, CacheAction, UploadArtifact, DownloadArtifact, NodeCI, NodePipeline, PythonCI, DockerBuild, DeployEnvironment, GoCI, Dependabot) + **3 presets** (BunPipeline, PnpmPipeline, YarnPipeline), a typed **Expression** system with 24 GitHub and 5 Runner context variables, and **13 lint rules** + **45 post-synth checks** (including a CI/CD supply-chain security pass, GHA029–058).
29
29
  `;
30
30
 
31
31
  const outputFormat = `The GitHub Actions lexicon serializes resources into **\`.github/workflows/*.yml\`** YAML files.
@@ -184,7 +184,7 @@ GitHub automatically picks up any \`.github/workflows/*.yml\` files and runs the
184
184
  - [Workflow Concepts](/chant/lexicons/github/workflow-concepts/) — resource types, triggers, permissions
185
185
  - [Expressions](/chant/lexicons/github/expressions/) — typed expression system and condition helpers
186
186
  - [Composites](/chant/lexicons/github/composites/) — pre-built action wrappers (Checkout, SetupNode, etc.)
187
- - [Lint Rules](/chant/lexicons/github/lint-rules/) — 13 lint rules and 6 post-synth checks`,
187
+ - [Lint Rules](/chant/lexicons/github/lint-rules/) — 13 lint rules and 45 post-synth checks`,
188
188
  },
189
189
  {
190
190
  slug: "workflow-concepts",
@@ -838,7 +838,27 @@ const { workflow, buildJob, testJob, lintJob } = GoCI({
838
838
  const noLint = GoCI({ lintCommand: null });
839
839
  \`\`\`
840
840
 
841
- **Props:** \`goVersion?\`, \`testCommand?\`, \`buildCommand?\`, \`lintCommand?\` (null to omit), \`runsOn?\``,
841
+ **Props:** \`goVersion?\`, \`testCommand?\`, \`buildCommand?\`, \`lintCommand?\` (null to omit), \`runsOn?\`
842
+
843
+ ## Dependabot
844
+
845
+ Models the repository's dependency-update configuration (\`.github/dependabot.yml\`) as a chant resource, so it is emitted and lintable like a workflow. The composite ships **safe defaults**: a cooldown window on every ecosystem (so a version published moments ago — including a compromised one — is not adopted before anyone can react) and external code execution explicitly denied.
846
+
847
+ \`\`\`typescript
848
+ import { Dependabot } from "@intentius/chant-lexicon-github";
849
+
850
+ export const dependabot = Dependabot({
851
+ ecosystems: [
852
+ { packageEcosystem: "npm", directory: "/" },
853
+ { packageEcosystem: "github-actions", directory: "/" },
854
+ ],
855
+ // cooldownDays defaults to 7, openPullRequestsLimit to 5
856
+ });
857
+ \`\`\`
858
+
859
+ **Props:** \`ecosystems\` (required — each \`{ packageEcosystem, directory?, interval? }\`), \`cooldownDays?\` (default 7), \`openPullRequestsLimit?\` (default 5)
860
+
861
+ For full control, construct the \`DependabotConfig\` resource directly with raw \`updates:\` entries. Two post-synth checks validate the emitted config: **GHA057** (\`insecure-external-code-execution: allow\`) and **GHA058** (no cooldown). See [Lint Rules](/chant/lexicons/github/lint-rules/).`,
842
862
  },
843
863
  {
844
864
  slug: "lint-rules",
@@ -976,6 +996,60 @@ Flags workflows triggered by \`pull_request_target\` that include \`actions/chec
976
996
 
977
997
  Detects cycles in the \`needs:\` dependency graph. If job A needs B and B needs A (directly or transitively), GitHub rejects the workflow. Reports the full cycle chain in the diagnostic message.
978
998
 
999
+ ### GHA021 — Checkout action not pinned to a SHA
1000
+
1001
+ **Severity:** warning
1002
+
1003
+ Flags \`actions/checkout\` referenced by a tag (e.g. \`@v4\`) instead of a pinned commit SHA. The narrower precursor to GHA029, kept because checkout is the most common unpinned action.
1004
+
1005
+ ### GHA022 — Job without timeout-minutes
1006
+
1007
+ **Severity:** info
1008
+
1009
+ Flags jobs that omit \`timeout-minutes\`. A hung step otherwise runs to the runner's default cap, burning minutes — set an explicit ceiling.
1010
+
1011
+ ### GHA023 — Deprecated set-output command
1012
+
1013
+ **Severity:** warning
1014
+
1015
+ Flags \`::set-output\` in \`run:\` steps. The workflow command is deprecated and disabled on current runners — write to \`$GITHUB_OUTPUT\` instead.
1016
+
1017
+ ### GHA024 — Missing concurrency for deploy workflows
1018
+
1019
+ **Severity:** info
1020
+
1021
+ Flags deploy workflows without a \`concurrency:\` block. Without one, two pushes can deploy concurrently and race — add a concurrency group to serialize them.
1022
+
1023
+ ### GHA025 — pull_request_target without restrictions
1024
+
1025
+ **Severity:** warning
1026
+
1027
+ Flags \`pull_request_target\` used without a \`types:\` filter. The trigger runs with repository secrets in the base-branch context, so it should be scoped to the specific PR events that need it.
1028
+
1029
+ ### GHA026 — Secret used without environment protection
1030
+
1031
+ **Severity:** info
1032
+
1033
+ Flags workflows that reference \`secrets.\` in steps but declare no \`environment:\` on any job, so the secret skips the approval and scoping rules an environment gate provides.
1034
+
1035
+ ### GHA027 — Cleanup step missing if: always()
1036
+
1037
+ **Severity:** info
1038
+
1039
+ Flags steps named "cleanup" / "teardown" / "clean up" that lack an \`if:\` condition. Cleanup should run even when a prior step fails — add \`if: always()\`.
1040
+
1041
+ ### GHA028 — Workflow with no on: triggers
1042
+
1043
+ **Severity:** error
1044
+
1045
+ Flags a workflow file with no top-level \`on:\` key. Without a trigger the workflow can never run.
1046
+
1047
+ ## Supply-chain security pass (GHA029–058)
1048
+
1049
+ GHA029 onward are a CI/CD supply-chain security pass: pin & vet external references, enforce least-privilege token scopes, guard trust boundaries against untrusted input, contain secrets, reject unsound expressions, and keep artifacts/caches honest. They run statically on the emitted YAML — everything answerable without leaving the build.
1050
+
1051
+ The checks that need a *moving external truth* — whether a pinned SHA still maps to a real upstream tag, whether a ref still exists, whether a new advisory now covers an action in use — can't be deterministic, so they live in the operational layer instead. Schedule the [\`WorkflowAuditOp\`](/chant/guide/ops/#audit-supply-chain-drift) (temporal lexicon) for that live, always-fresh half; it reads the same emitted workflow references and reports drift via \`report | issue | pull-request\`.
1052
+
979
1053
  ### GHA029 — Action or reusable workflow not pinned to a commit SHA
980
1054
 
981
1055
  **Severity:** warning
@@ -1287,7 +1361,7 @@ The inline \`chant-github\` skill covers the full workflow lifecycle:
1287
1361
  - **Validate** — \`chant lint src/\`
1288
1362
  - **Deploy** — commit and push the generated YAML
1289
1363
  - **Status** — GitHub Actions UI or \`gh run list\`
1290
- - **Troubleshooting** — lint rule codes (GHA001–GHA020), post-synth checks (GHA006–GHA019)
1364
+ - **Troubleshooting** — lint rule codes (GHA001–GHA020), post-synth checks (GHA006–GHA058)
1291
1365
 
1292
1366
  The skill is invocable as a slash command: \`/chant-github\`
1293
1367
 
@@ -1311,6 +1385,13 @@ The lexicon provides MCP (Model Context Protocol) tools and resources that AI ag
1311
1385
  | MCP tool | Description |
1312
1386
  |----------|-------------|
1313
1387
  | \`diff\` | Compare current build output against previous output |
1388
+ | \`github:checks\` | Build the workflow and return its security findings (the GHA checks) |
1389
+ | \`github:workflow\` | Triggers and jobs as written — name, run order, step count |
1390
+ | \`github:references\` | Actions and images pulled in, and whether each is pinned to a commit SHA |
1391
+ | \`github:affected\` | Given a job, the jobs that would re-run because they depend on it |
1392
+ | \`github:workflow-yaml\` | The generated workflow YAML |
1393
+
1394
+ The \`github:*\` tools are **read-only context tools** (#327): each builds from your source and returns what chant already computes — before the workflow runs or merges. None touch the live GitHub instance, run history, or write anything.
1314
1395
 
1315
1396
  | MCP resource | Description |
1316
1397
  |--------------|-------------|
@@ -0,0 +1,108 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { githubContextTools, downstreamJobs, actionPinned } from "./context-tools";
6
+
7
+ const tools = Object.fromEntries(githubContextTools().map((t) => [t.name, t]));
8
+
9
+ async function call(name: string, params: Record<string, unknown>): Promise<unknown> {
10
+ return tools[name].handler(params);
11
+ }
12
+
13
+ describe("downstreamJobs / actionPinned (#327)", () => {
14
+ test("downstreamJobs follows the needs chain transitively", () => {
15
+ const yaml = `jobs:
16
+ build:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - run: echo build
20
+ test:
21
+ runs-on: ubuntu-latest
22
+ needs: [build]
23
+ steps:
24
+ - run: echo test
25
+ deploy:
26
+ runs-on: ubuntu-latest
27
+ needs: [test]
28
+ steps:
29
+ - run: echo deploy
30
+ `;
31
+ expect(downstreamJobs(yaml, "build").sort()).toEqual(["deploy", "test"]);
32
+ expect(downstreamJobs(yaml, "test")).toEqual(["deploy"]);
33
+ expect(downstreamJobs(yaml, "deploy")).toEqual([]);
34
+ });
35
+
36
+ test("actionPinned only accepts a full commit SHA", () => {
37
+ expect(actionPinned("actions/checkout@" + "a".repeat(40))).toBe(true);
38
+ expect(actionPinned("actions/checkout@v4")).toBe(false);
39
+ expect(actionPinned("actions/checkout@main")).toBe(false);
40
+ });
41
+ });
42
+
43
+ describe("github context tools — end to end (#327)", () => {
44
+ let dir: string;
45
+ beforeEach(async () => {
46
+ dir = join(tmpdir(), `chant-mcp-gh-${Date.now()}-${Math.random().toString(36).slice(2)}`);
47
+ await mkdir(dir, { recursive: true });
48
+ await writeFile(
49
+ join(dir, "workflow.github.ts"),
50
+ `const M = Symbol.for("chant.declarable");
51
+ export const ci = { [M]: true, lexicon: "github", entityType: "GitHub::Actions::Workflow", kind: "resource",
52
+ props: {
53
+ name: "CI",
54
+ on: { push: { branches: ["main"] } },
55
+ permissions: { contents: "read" },
56
+ jobs: {
57
+ build: { runsOn: "ubuntu-latest", steps: [ { uses: "peter-evans/create-pull-request@v6" }, { run: "npm ci" } ] },
58
+ test: { runsOn: "ubuntu-latest", needs: ["build"], steps: [ { run: "npm test" } ] },
59
+ },
60
+ } };
61
+ `,
62
+ );
63
+ });
64
+ afterEach(async () => {
65
+ await rm(dir, { recursive: true, force: true });
66
+ });
67
+
68
+ test("github:workflow returns triggers and jobs", async () => {
69
+ const out = (await call("github:workflow", { path: dir })) as {
70
+ workflow: string | null;
71
+ triggers: string[];
72
+ jobs: Array<{ name: string; runsAfter: string[]; steps: number }>;
73
+ };
74
+ expect(out.workflow).toBe("CI");
75
+ expect(out.triggers).toContain("push");
76
+ const names = out.jobs.map((j) => j.name);
77
+ expect(names).toEqual(expect.arrayContaining(["build", "test"]));
78
+ expect(out.jobs.find((j) => j.name === "test")?.runsAfter).toContain("build");
79
+ });
80
+
81
+ test("github:references reports the unpinned third-party action", async () => {
82
+ const out = (await call("github:references", { path: dir })) as Array<{ kind: string; source: string; pinned: boolean }>;
83
+ const action = out.find((r) => r.kind === "action" && r.source === "peter-evans/create-pull-request@v6");
84
+ expect(action).toBeDefined();
85
+ expect(action?.pinned).toBe(false);
86
+ });
87
+
88
+ test("github:checks returns the GHA findings (unpinned action trips GHA029)", async () => {
89
+ const out = (await call("github:checks", { path: dir })) as { findings: Array<{ id: string; severity: string; job: string | null; message: string }> };
90
+ expect(Array.isArray(out.findings)).toBe(true);
91
+ expect(out.findings.some((f) => f.id === "GHA029")).toBe(true);
92
+ for (const f of out.findings) {
93
+ expect(typeof f.id).toBe("string");
94
+ expect(typeof f.message).toBe("string");
95
+ }
96
+ });
97
+
98
+ test("github:affected lists downstream jobs", async () => {
99
+ const out = (await call("github:affected", { path: dir, job: "build" })) as { job: string; wouldRerun: string[] };
100
+ expect(out.wouldRerun).toContain("test");
101
+ });
102
+
103
+ test("github:workflow-yaml returns the generated YAML for a path", async () => {
104
+ const out = (await call("github:workflow-yaml", { path: dir })) as { yaml: string };
105
+ expect(typeof out.yaml).toBe("string");
106
+ expect(out.yaml).toContain("jobs:");
107
+ });
108
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Read-only MCP tools that expose what `chant build` already computes about a
3
+ * GitHub Actions workflow — its jobs and run order, what it pulls in from
4
+ * outside (actions, images), and its security findings — so an agent can ask
5
+ * about a change *before it runs or merges* (#327).
6
+ *
7
+ * The GitHub counterpart to the GitLab context tools. Every tool builds from
8
+ * source and returns data. None of them touch the live GitHub instance, read
9
+ * run history, or write anything — that boundary is what keeps chant a context
10
+ * producer and not a copy of the live tooling.
11
+ */
12
+
13
+ import { build, type BuildResult } from "@intentius/chant/build";
14
+ import { runPostSynthChecks, getPrimaryOutput } from "@intentius/chant/lint/post-synth";
15
+ import { discoverPostSynthChecks } from "@intentius/chant/lint/discover";
16
+ import type { McpToolContribution } from "@intentius/chant/mcp/types";
17
+ import { dirname, join } from "path";
18
+ import { fileURLToPath } from "url";
19
+ import { githubSerializer } from "../serializer";
20
+ import {
21
+ extractJobs,
22
+ extractActionRefs,
23
+ extractImageRefs,
24
+ extractTriggers,
25
+ extractWorkflowName,
26
+ } from "../lint/post-synth/yaml-helpers";
27
+
28
+ /** Build the project and return the emitted GitHub workflow YAML plus the raw result. */
29
+ async function buildGithub(path: string): Promise<{ yaml: string; result: BuildResult }> {
30
+ const result = await build(path, [githubSerializer]);
31
+ const out = result.outputs.get("github");
32
+ return { yaml: out ? getPrimaryOutput(out) : "", result };
33
+ }
34
+
35
+ /** Discover the lexicon's post-synth checks without depending on the plugin. */
36
+ function githubPostSynthChecks() {
37
+ const dir = join(dirname(fileURLToPath(import.meta.url)), "..", "lint", "post-synth");
38
+ return discoverPostSynthChecks(dir, import.meta.url);
39
+ }
40
+
41
+ /** Is a `uses:` ref pinned to a full commit SHA (the only immutable form)? */
42
+ export function actionPinned(ref: string): boolean {
43
+ const at = ref.lastIndexOf("@");
44
+ return at !== -1 && /^[0-9a-f]{40}$/.test(ref.slice(at + 1));
45
+ }
46
+
47
+ /** Jobs that would re-run because they depend (transitively) on `job`. */
48
+ export function downstreamJobs(yaml: string, job: string): string[] {
49
+ const jobs = extractJobs(yaml);
50
+ // edge: upstream -> downstream (J depends on each of its needs)
51
+ const downstreamOf = new Map<string, string[]>();
52
+ for (const [name, j] of jobs) {
53
+ for (const dep of j.needs ?? []) {
54
+ const arr = downstreamOf.get(dep) ?? [];
55
+ arr.push(name);
56
+ downstreamOf.set(dep, arr);
57
+ }
58
+ }
59
+ const out = new Set<string>();
60
+ const queue = [job];
61
+ while (queue.length) {
62
+ const cur = queue.shift()!;
63
+ for (const next of downstreamOf.get(cur) ?? []) {
64
+ if (!out.has(next)) {
65
+ out.add(next);
66
+ queue.push(next);
67
+ }
68
+ }
69
+ }
70
+ return [...out];
71
+ }
72
+
73
+ const PATH_INPUT = {
74
+ type: "object" as const,
75
+ properties: {
76
+ path: { type: "string", description: "Path to the chant project directory (default: current directory)" },
77
+ },
78
+ };
79
+
80
+ /** The read-only context tools for the GitHub lexicon. */
81
+ export function githubContextTools(): McpToolContribution[] {
82
+ return [
83
+ {
84
+ name: "github:checks",
85
+ description:
86
+ "Build the workflow and return its security and correctness findings (the GHA checks) as JSON. Read-only — does not touch the live GitHub instance.",
87
+ inputSchema: PATH_INPUT,
88
+ async handler(params: Record<string, unknown>): Promise<unknown> {
89
+ const { yaml, result } = await buildGithub((params.path as string) ?? ".");
90
+ if (!yaml) return { findings: [], note: "no GitHub workflow produced from this project" };
91
+ const scoped = { ...result, outputs: new Map([["github", result.outputs.get("github")!]]) };
92
+ const diags = runPostSynthChecks(githubPostSynthChecks(), scoped);
93
+ return {
94
+ findings: diags.map((d) => ({
95
+ id: d.checkId,
96
+ severity: d.severity,
97
+ job: d.entity ?? null,
98
+ message: d.message,
99
+ })),
100
+ };
101
+ },
102
+ },
103
+ {
104
+ name: "github:workflow",
105
+ description:
106
+ "Build the workflow and return its triggers and jobs as written (name, what each job runs after, step count) — before anything runs. Read-only.",
107
+ inputSchema: PATH_INPUT,
108
+ async handler(params: Record<string, unknown>): Promise<unknown> {
109
+ const { yaml } = await buildGithub((params.path as string) ?? ".");
110
+ const jobs = [...extractJobs(yaml).values()].map((j) => ({
111
+ name: j.name,
112
+ runsAfter: j.needs ?? [],
113
+ steps: j.steps?.length ?? 0,
114
+ }));
115
+ return {
116
+ workflow: extractWorkflowName(yaml) ?? null,
117
+ triggers: Object.keys(extractTriggers(yaml)),
118
+ jobs,
119
+ };
120
+ },
121
+ },
122
+ {
123
+ name: "github:references",
124
+ description:
125
+ "Build the workflow and list everything it pulls in from outside (actions via uses:, container/service images) and whether each is pinned to an immutable commit SHA. Read-only.",
126
+ inputSchema: PATH_INPUT,
127
+ async handler(params: Record<string, unknown>): Promise<unknown> {
128
+ const { yaml } = await buildGithub((params.path as string) ?? ".");
129
+ const actions = extractActionRefs(yaml).map((a) => ({
130
+ kind: a.level === "job" ? ("reusable-workflow" as const) : ("action" as const),
131
+ job: a.job,
132
+ source: a.ref,
133
+ pinned: actionPinned(a.ref),
134
+ }));
135
+ const images = extractImageRefs(yaml).map((i) => ({
136
+ kind: "image" as const,
137
+ job: i.job,
138
+ source: i.image,
139
+ pinned: i.image.includes("@sha256:"),
140
+ }));
141
+ return [...actions, ...images];
142
+ },
143
+ },
144
+ {
145
+ name: "github:affected",
146
+ description:
147
+ "Build the workflow and, given a job name, list the jobs that would re-run because they depend on it (the needs chain). Read-only.",
148
+ inputSchema: {
149
+ type: "object" as const,
150
+ properties: {
151
+ path: { type: "string", description: "Path to the chant project directory (default: current directory)" },
152
+ job: { type: "string", description: "Job name to trace downstream from" },
153
+ },
154
+ required: ["job"],
155
+ },
156
+ async handler(params: Record<string, unknown>): Promise<unknown> {
157
+ const { yaml } = await buildGithub((params.path as string) ?? ".");
158
+ const job = params.job as string;
159
+ return { job, wouldRerun: downstreamJobs(yaml, job) };
160
+ },
161
+ },
162
+ {
163
+ name: "github:workflow-yaml",
164
+ description: "Build the project and return the generated GitHub Actions workflow YAML as a string. Read-only.",
165
+ inputSchema: PATH_INPUT,
166
+ async handler(params: Record<string, unknown>): Promise<unknown> {
167
+ const { yaml } = await buildGithub((params.path as string) ?? ".");
168
+ return { yaml };
169
+ },
170
+ },
171
+ ];
172
+ }
@@ -125,8 +125,17 @@ describe("githubPlugin", () => {
125
125
 
126
126
  test("provides MCP tools", () => {
127
127
  const tools = githubPlugin.mcpTools!();
128
- expect(tools.length).toBe(1);
129
- expect(tools[0].name).toBe("github:diff");
128
+ const names = tools.map((t) => t.name);
129
+ expect(names).toContain("github:diff");
130
+ // Read-only context tools (#327)
131
+ expect(names).toContain("github:checks");
132
+ expect(names).toContain("github:workflow");
133
+ expect(names).toContain("github:references");
134
+ expect(names).toContain("github:affected");
135
+ expect(names).toContain("github:workflow-yaml");
136
+ for (const t of tools) {
137
+ expect(typeof t.handler).toBe("function");
138
+ }
130
139
  });
131
140
 
132
141
  test("provides MCP resources", () => {
package/src/plugin.ts CHANGED
@@ -29,6 +29,7 @@ import { githubCompletions } from "./lsp/completions";
29
29
  import { githubHover } from "./lsp/hover";
30
30
  import { GitHubActionsParser } from "./import/parser";
31
31
  import { GitHubActionsGenerator } from "./import/generator";
32
+ import { githubContextTools } from "./mcp/context-tools";
32
33
 
33
34
  export const githubPlugin: LexiconPlugin = {
34
35
  name: "github",
@@ -224,7 +225,10 @@ export const build = new Job({
224
225
  },
225
226
 
226
227
  mcpTools() {
227
- return [createDiffTool(githubSerializer, "Compare current build output against previous output for GitHub Actions", "github")];
228
+ return [
229
+ createDiffTool(githubSerializer, "Compare current build output against previous output for GitHub Actions", "github"),
230
+ ...githubContextTools(),
231
+ ];
228
232
  },
229
233
 
230
234
  mcpResources() {