pi-forgejo-mcp 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial public Pi extension for Forgejo via `forgejo-mcp --cli`.
6
+ - Adds status, tool discovery, tool help, and generic operation invocation tools.
7
+ - Adds interactive confirmation for mutating Forgejo operations.
@@ -0,0 +1,79 @@
1
+ # Contributing
2
+
3
+ Thanks for your interest in contributing to `pi-forgejo-mcp`.
4
+
5
+ ## Prerequisites
6
+
7
+ - Node.js `>=22.19.0`
8
+ - npm
9
+ - Pi installed locally for interactive extension testing
10
+ - `forgejo-mcp` on your `PATH` if you want to test against a real Forgejo instance
11
+
12
+ Install dependencies with:
13
+
14
+ ```sh
15
+ npm ci
16
+ ```
17
+
18
+ ## Local development
19
+
20
+ Run the full check suite before opening a pull request:
21
+
22
+ ```sh
23
+ npm run check
24
+ ```
25
+
26
+ This runs TypeScript, ESLint, Prettier, and Vitest.
27
+
28
+ For interactive local testing:
29
+
30
+ ```sh
31
+ pi -e .
32
+ ```
33
+
34
+ ## Formatting
35
+
36
+ Check formatting with:
37
+
38
+ ```sh
39
+ npm run format
40
+ ```
41
+
42
+ Apply formatting with:
43
+
44
+ ```sh
45
+ npm run format:write
46
+ ```
47
+
48
+ ## Testing with Forgejo
49
+
50
+ For real Forgejo calls, set credentials in the environment of the shell that launches Pi:
51
+
52
+ ```sh
53
+ export FORGEJO_URL="https://codeberg.org"
54
+ export FORGEJO_ACCESS_TOKEN="<your personal access token>"
55
+ pi -e .
56
+ ```
57
+
58
+ Never commit real tokens. `.env` and `.env.*` files are gitignored for local use, and Pi does not load them automatically.
59
+
60
+ ## Pull requests
61
+
62
+ Please keep changes focused and include relevant updates when applicable:
63
+
64
+ - tests for behavior changes
65
+ - documentation for user-facing changes
66
+ - changelog entries for release-worthy changes
67
+ - security notes for changes affecting token handling, command execution, or mutations
68
+
69
+ Before submitting, run:
70
+
71
+ ```sh
72
+ npm run check
73
+ ```
74
+
75
+ ## Security
76
+
77
+ Please do not include access tokens, private repository details, issue contents, or other sensitive data in issues, pull requests, tests, or logs.
78
+
79
+ See [SECURITY.md](SECURITY.md) for vulnerability reporting and token-handling guidance.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ben Osborne
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,221 @@
1
+ # pi-forgejo-mcp
2
+
3
+ A [Pi](https://pi.dev/) extension for using Forgejo from Pi via the official [`forgejo-mcp`](https://codeberg.org/goern/forgejo-mcp) CLI.
4
+
5
+ Use it to ask Pi to list repositories, inspect issues, create pull requests, read files, check workflow runs, manage releases, and call other Forgejo operations exposed by `forgejo-mcp`.
6
+
7
+ ## Install
8
+
9
+ First install the official Forgejo MCP CLI:
10
+
11
+ ```sh
12
+ go install codeberg.org/goern/forgejo-mcp/v2@latest
13
+ ```
14
+
15
+ Make sure `forgejo-mcp` is on your `PATH`:
16
+
17
+ ```sh
18
+ forgejo-mcp --help
19
+ ```
20
+
21
+ ### Install by asking Pi
22
+
23
+ If you already have Pi installed, you can paste this prompt into Pi. It asks Pi to check prerequisites, install the extension, and then explain the remaining authentication steps:
24
+
25
+ ```text
26
+ Please install the Forgejo MCP Pi extension for me.
27
+
28
+ Before installing, remind me that Pi packages/extensions run with my local user permissions. Ask me for confirmation before running installation commands.
29
+
30
+ Do the setup:
31
+ 1. Check whether `forgejo-mcp` is installed and on PATH.
32
+ 2. If it is missing, check whether `go` is available. If Go is available, install the official CLI with:
33
+ `go install codeberg.org/goern/forgejo-mcp/v2@latest`
34
+ Then verify `forgejo-mcp --help` works. If it does not, tell me to add `$(go env GOPATH)/bin` to PATH or configure `FORGEJO_MCP_COMMAND`.
35
+ If Go is not available, stop and tell me to install Go or configure `FORGEJO_MCP_COMMAND`.
36
+ 3. Install the Pi package:
37
+ `pi install git:codeberg.org/ozzy92/pi-forgejo-mcp@main`
38
+ 4. Do not ask me to paste a token into chat. Tell me to set `FORGEJO_URL` and `FORGEJO_ACCESS_TOKEN` in the shell that launches Pi, using 1Password, Bitwarden, direnv, or another secret store.
39
+ 5. Tell me to restart Pi or run `/reload`.
40
+ 6. After reload/restart, tell me to run: `Check Forgejo MCP status.`
41
+ ```
42
+
43
+ For repeatable installs, ask Pi to install a tagged release such as `git:codeberg.org/ozzy92/pi-forgejo-mcp@v0.1.0` instead of `main`.
44
+
45
+ ### Manual install
46
+
47
+ Install this Pi package from Codeberg:
48
+
49
+ ```sh
50
+ pi install git:codeberg.org/ozzy92/pi-forgejo-mcp@main
51
+ ```
52
+
53
+ For repeatable installs, install a tagged release instead of `main`:
54
+
55
+ ```sh
56
+ pi install git:codeberg.org/ozzy92/pi-forgejo-mcp@v0.1.0
57
+ ```
58
+
59
+ Restart Pi or run `/reload` in an existing Pi session.
60
+
61
+ > **Security:** Pi packages and extensions run with your local user permissions. Review the source before installing third-party packages.
62
+
63
+ ## Authenticate with Forgejo
64
+
65
+ Create a Forgejo personal access token with the permissions you need for the operations you want Pi to perform.
66
+
67
+ The token lives in the environment of the `pi` process:
68
+
69
+ ```sh
70
+ export FORGEJO_URL="https://codeberg.org" # or your Forgejo instance URL
71
+ export FORGEJO_ACCESS_TOKEN="<your personal access token>"
72
+ pi
73
+ ```
74
+
75
+ Do **not** commit the token. Do not put it in this repository, `package.json`, Pi settings, prompts, or session files.
76
+
77
+ Good places to keep the token:
78
+
79
+ - your password manager, injected into the shell before launching Pi
80
+ - a local, gitignored shell file sourced by your shell profile
81
+ - a [`direnv`](https://direnv.net/) `.envrc` file that is not committed
82
+ - CI/secret-store environment variables for non-interactive runs
83
+
84
+ Example with 1Password CLI:
85
+
86
+ ```sh
87
+ export FORGEJO_URL="https://codeberg.org"
88
+ export FORGEJO_ACCESS_TOKEN="$(op read 'op://Private/Codeberg Forgejo/token')"
89
+ pi
90
+ ```
91
+
92
+ Example with Bitwarden CLI:
93
+
94
+ ```sh
95
+ export BW_SESSION="$(bw unlock --raw)"
96
+ export FORGEJO_URL="https://codeberg.org"
97
+ export FORGEJO_ACCESS_TOKEN="$(bw get password 'Codeberg Forgejo token')"
98
+ pi
99
+ ```
100
+
101
+ See [SECURITY.md](SECURITY.md) for vulnerability reporting, token-handling guidance, and local execution notes.
102
+
103
+ ## Try it in Pi
104
+
105
+ Ask Pi things like:
106
+
107
+ ```text
108
+ Check Forgejo MCP status.
109
+ List Forgejo MCP issue tools.
110
+ Describe the list_repo_issues Forgejo MCP tool.
111
+ List open issues in my-org/my-repo.
112
+ Create an issue in my-org/my-repo titled "Improve docs" with body "Add screenshots".
113
+ Show recent workflow runs for my-org/my-repo.
114
+ ```
115
+
116
+ Pi will usually discover or describe the Forgejo operation first, then call it with JSON arguments.
117
+
118
+ ## Tools provided to Pi
119
+
120
+ When Pi loads this extension, it registers four tools:
121
+
122
+ | Tool | Purpose |
123
+ | --------------------------- | --------------------------------------------------------------------------------------------------- |
124
+ | `forgejo_mcp_status` | Check whether `forgejo-mcp` is installed and whether runtime auth env vars are present. |
125
+ | `forgejo_mcp_list_tools` | List operations exposed by the installed `forgejo-mcp` binary, optionally filtered by domain/query. |
126
+ | `forgejo_mcp_describe_tool` | Show the parameters for one Forgejo MCP operation. |
127
+ | `forgejo_mcp_call` | Invoke one Forgejo MCP operation with JSON arguments. |
128
+
129
+ If you are driving Pi programmatically, `forgejo_mcp_call` accepts this shape:
130
+
131
+ ```json
132
+ {
133
+ "tool": "list_repo_issues",
134
+ "args": {
135
+ "owner": "my-org",
136
+ "repo": "my-repo",
137
+ "state": "open"
138
+ }
139
+ }
140
+ ```
141
+
142
+ ## How it works
143
+
144
+ This extension does not implement the Forgejo API itself. It shells out to the official CLI:
145
+
146
+ ```sh
147
+ forgejo-mcp --cli <operation> --args '<json>' --output=json
148
+ ```
149
+
150
+ Read-only discovery (`forgejo_mcp_list_tools` and `forgejo_mcp_describe_tool`) uses dummy local environment variables because `forgejo-mcp` requires configuration even for local help output.
151
+
152
+ Real Forgejo calls use `FORGEJO_URL` and `FORGEJO_ACCESS_TOKEN` from the Pi process environment.
153
+
154
+ Large outputs are truncated to Pi's default tool-output limit, and the full output is written to a temp file. Captured stdout/stderr is sanitized so the configured access token is replaced before tool results are returned to Pi.
155
+
156
+ ## Safety model
157
+
158
+ Operations whose names look mutating (`create_*`, `update_*`, `delete_*`, `merge_*`, `mark_*`, etc.) ask for confirmation in interactive Pi sessions.
159
+
160
+ In non-interactive modes, mutating operations are blocked unless you explicitly set:
161
+
162
+ ```sh
163
+ export FORGEJO_MCP_ALLOW_MUTATIONS=true
164
+ ```
165
+
166
+ You can disable interactive confirmations with:
167
+
168
+ ```sh
169
+ export FORGEJO_MCP_CONFIRM_MUTATIONS=false
170
+ ```
171
+
172
+ When confirmations are disabled, mutating operations are treated like non-interactive runs and still require:
173
+
174
+ ```sh
175
+ export FORGEJO_MCP_ALLOW_MUTATIONS=true
176
+ ```
177
+
178
+ ## Configuration
179
+
180
+ | Variable | Default | Purpose |
181
+ | ------------------------------- | ----------------- | --------------------------------------------------------------- |
182
+ | `FORGEJO_URL` | required | Forgejo instance URL, for example `https://codeberg.org`. |
183
+ | `FORGEJO_ACCESS_TOKEN` | required | Forgejo personal access token. |
184
+ | `FORGEJO_MCP_COMMAND` | `forgejo-mcp` | Binary to execute. Use this if `forgejo-mcp` is not on `PATH`. |
185
+ | `FORGEJO_MCP_TIMEOUT_MS` | `60000` | Timeout per CLI call. |
186
+ | `FORGEJO_MCP_MAX_CAPTURE_BYTES` | `26214400` | Maximum stdout/stderr captured before killing the process. |
187
+ | `FORGEJO_MCP_CONFIRM_MUTATIONS` | `true` in UI mode | Set to `false` to skip interactive confirmation. |
188
+ | `FORGEJO_MCP_ALLOW_MUTATIONS` | unset | Required for mutating operations when no confirmation is taken. |
189
+
190
+ ## Local development
191
+
192
+ ```sh
193
+ git clone https://codeberg.org/ozzy92/pi-forgejo-mcp.git
194
+ cd pi-forgejo-mcp
195
+ npm install
196
+ npm run check
197
+ pi -e .
198
+ ```
199
+
200
+ `npm run check` runs TypeScript, ESLint, Prettier, and Vitest.
201
+
202
+ For local testing against a real Forgejo instance, copy the example environment file and source it before launching Pi:
203
+
204
+ ```sh
205
+ cp .env.example .env.local
206
+ # edit .env.local with your own token
207
+ set -a; source .env.local; set +a
208
+ pi -e .
209
+ ```
210
+
211
+ Pi does not automatically load `.env` files. The `.env` and `.env.*` patterns are gitignored; keep real tokens out of commits.
212
+
213
+ Or install the local package globally while developing:
214
+
215
+ ```sh
216
+ pi install /absolute/path/to/pi-forgejo-mcp
217
+ ```
218
+
219
+ ## License
220
+
221
+ MIT
package/SECURITY.md ADDED
@@ -0,0 +1,40 @@
1
+ # Security
2
+
3
+ `pi-forgejo-mcp` is an open-source Pi extension that shells out to the official `forgejo-mcp` CLI. It does not implement Forgejo authentication itself.
4
+
5
+ ## Supported versions
6
+
7
+ Security fixes are intended for the latest released version and the `main` branch. Older tags may not receive backports.
8
+
9
+ ## Reporting vulnerabilities
10
+
11
+ Please do not publish access tokens, private repository names, issue contents, or other sensitive data in public reports.
12
+
13
+ - For issues that can be discussed publicly, open a Codeberg issue: <https://codeberg.org/ozzy92/pi-forgejo-mcp/issues>
14
+ - For sensitive vulnerability details, use any private contact method listed on the project or maintainer profile first. If no private channel is available, open a minimal public issue asking for a private contact path and include only high-level impact and affected components.
15
+
16
+ ## Secret handling
17
+
18
+ The extension reads Forgejo credentials from the environment of the running Pi process:
19
+
20
+ - `FORGEJO_URL`
21
+ - `FORGEJO_ACCESS_TOKEN`
22
+
23
+ The extension does not read `.env` files automatically, does not write tokens to Pi settings, and does not require tokens in tool-call arguments.
24
+
25
+ Captured stdout/stderr is sanitized before being returned to Pi by replacing the configured access token with `<FORGEJO_ACCESS_TOKEN>`. Do not rely on sanitization as your only control: avoid pasting secrets into prompts, issue bodies, pull request text, or tool arguments.
26
+
27
+ ## Token guidance
28
+
29
+ - Use a token with the least permissions needed for the operations you want Pi to perform.
30
+ - Store the token in a password manager, `direnv`, your shell environment, or a CI/secret-store environment variable.
31
+ - Never commit real tokens or include them in examples, prompts, logs, issues, or pull requests.
32
+ - Rotate the token immediately if it is exposed.
33
+
34
+ ## Local execution trust
35
+
36
+ Pi packages and extensions run with your local user permissions. Only install this package, Pi, and the `forgejo-mcp` binary from sources you trust.
37
+
38
+ This extension executes `forgejo-mcp` from `PATH` by default, or from `FORGEJO_MCP_COMMAND` if set. Forgejo operations run with the permissions granted to `FORGEJO_ACCESS_TOKEN`.
39
+
40
+ Mutating Forgejo operations ask for confirmation in interactive Pi sessions. If no confirmation is taken, either because Pi is non-interactive or `FORGEJO_MCP_CONFIRM_MUTATIONS=false` is set, mutating operations are blocked unless `FORGEJO_MCP_ALLOW_MUTATIONS=true` is set.
@@ -0,0 +1,308 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdtemp, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ DEFAULT_MAX_BYTES,
8
+ DEFAULT_MAX_LINES,
9
+ formatSize,
10
+ truncateHead,
11
+ type TruncationResult,
12
+ } from "@earendil-works/pi-coding-agent";
13
+ import { confirmMutationIfNeeded } from "./mutations.ts";
14
+
15
+ const DEFAULT_TIMEOUT_MS = 60_000;
16
+ const DEFAULT_MAX_CAPTURE_BYTES = 25 * 1024 * 1024;
17
+ const DUMMY_FORGEJO_URL = "https://example.invalid";
18
+ const DUMMY_FORGEJO_TOKEN = "dummy-token-for-tool-discovery";
19
+
20
+ const toolNamePattern = /^[a-z0-9_]+$/;
21
+
22
+ export type ForgejoToolSummary = {
23
+ name: string;
24
+ description?: string;
25
+ domain?: string;
26
+ };
27
+
28
+ export type CommandResult = {
29
+ stdout: string;
30
+ stderr: string;
31
+ code: number | null;
32
+ signal: NodeJS.Signals | null;
33
+ timedOut: boolean;
34
+ };
35
+
36
+ export type FormattedOutput = {
37
+ text: string;
38
+ details: {
39
+ truncated: boolean;
40
+ truncation?: TruncationResult;
41
+ fullOutputPath?: string;
42
+ stderr?: string;
43
+ };
44
+ };
45
+
46
+ export function getCommand(): string {
47
+ return process.env.FORGEJO_MCP_COMMAND?.trim() || "forgejo-mcp";
48
+ }
49
+
50
+ export function getTimeoutMs(): number {
51
+ const value = Number.parseInt(process.env.FORGEJO_MCP_TIMEOUT_MS ?? "", 10);
52
+ return Number.isFinite(value) && value > 0 ? value : DEFAULT_TIMEOUT_MS;
53
+ }
54
+
55
+ export function getMaxCaptureBytes(): number {
56
+ const value = Number.parseInt(process.env.FORGEJO_MCP_MAX_CAPTURE_BYTES ?? "", 10);
57
+ return Number.isFinite(value) && value > 0 ? value : DEFAULT_MAX_CAPTURE_BYTES;
58
+ }
59
+
60
+ export function discoveryEnv(): NodeJS.ProcessEnv {
61
+ return {
62
+ ...process.env,
63
+ // Tool discovery/help is local-only and does not need real credentials.
64
+ FORGEJO_URL: DUMMY_FORGEJO_URL,
65
+ FORGEJO_ACCESS_TOKEN: DUMMY_FORGEJO_TOKEN,
66
+ };
67
+ }
68
+
69
+ export function requireRuntimeEnv(): NodeJS.ProcessEnv {
70
+ const missing: string[] = [];
71
+ if (!process.env.FORGEJO_URL) missing.push("FORGEJO_URL");
72
+ if (!process.env.FORGEJO_ACCESS_TOKEN) missing.push("FORGEJO_ACCESS_TOKEN");
73
+
74
+ if (missing.length > 0) {
75
+ throw new Error(
76
+ `Forgejo MCP is not configured. Missing ${missing.join(", ")}. ` +
77
+ "Set FORGEJO_URL and FORGEJO_ACCESS_TOKEN in the environment before starting Pi.",
78
+ );
79
+ }
80
+
81
+ return process.env;
82
+ }
83
+
84
+ export function sanitizeText(text: string, token = process.env.FORGEJO_ACCESS_TOKEN): string {
85
+ if (!token || token.length < 4) return text;
86
+ return text.split(token).join("<FORGEJO_ACCESS_TOKEN>");
87
+ }
88
+
89
+ export function truncateForError(text: string, max = 4_000): string {
90
+ const cleaned = sanitizeText(text.trim());
91
+ if (cleaned.length <= max) return cleaned;
92
+ return `${cleaned.slice(0, max)}\n...[truncated]`;
93
+ }
94
+
95
+ export function validateToolName(tool: string): string {
96
+ const trimmed = tool.trim();
97
+ if (!toolNamePattern.test(trimmed)) {
98
+ throw new Error(`Invalid forgejo-mcp tool name: ${tool}`);
99
+ }
100
+ return trimmed;
101
+ }
102
+
103
+ export async function runForgejo(
104
+ args: string[],
105
+ options: { env: NodeJS.ProcessEnv; signal?: AbortSignal },
106
+ ): Promise<CommandResult> {
107
+ const command = getCommand();
108
+ const timeoutMs = getTimeoutMs();
109
+ const maxCaptureBytes = getMaxCaptureBytes();
110
+
111
+ return new Promise<CommandResult>((resolve, reject) => {
112
+ let stdoutBytes = 0;
113
+ let stderrBytes = 0;
114
+ let timedOut = false;
115
+ let exceededCapture = false;
116
+ let closed = false;
117
+ let killStarted = false;
118
+ const stdoutChunks: Buffer[] = [];
119
+ const stderrChunks: Buffer[] = [];
120
+
121
+ const child = spawn(command, args, {
122
+ env: options.env,
123
+ stdio: ["ignore", "pipe", "pipe"],
124
+ });
125
+
126
+ const cleanupFns: Array<() => void> = [];
127
+
128
+ const kill = (reason: "timeout" | "abort" | "capture") => {
129
+ if (reason === "timeout") timedOut = true;
130
+ if (reason === "capture") exceededCapture = true;
131
+ if (killStarted) return;
132
+ killStarted = true;
133
+ if (!closed) child.kill("SIGTERM");
134
+ const killTimer = setTimeout(() => {
135
+ if (!closed) child.kill("SIGKILL");
136
+ }, 2_000);
137
+ cleanupFns.push(() => clearTimeout(killTimer));
138
+ };
139
+
140
+ const timeout = setTimeout(() => kill("timeout"), timeoutMs);
141
+ cleanupFns.push(() => clearTimeout(timeout));
142
+
143
+ const onAbort = () => kill("abort");
144
+ if (options.signal) {
145
+ if (options.signal.aborted) onAbort();
146
+ else {
147
+ options.signal.addEventListener("abort", onAbort, { once: true });
148
+ cleanupFns.push(() => options.signal?.removeEventListener("abort", onAbort));
149
+ }
150
+ }
151
+
152
+ child.stdout?.on("data", (chunk: Buffer) => {
153
+ stdoutBytes += chunk.length;
154
+ if (stdoutBytes + stderrBytes > maxCaptureBytes) {
155
+ kill("capture");
156
+ return;
157
+ }
158
+ stdoutChunks.push(chunk);
159
+ });
160
+
161
+ child.stderr?.on("data", (chunk: Buffer) => {
162
+ stderrBytes += chunk.length;
163
+ if (stdoutBytes + stderrBytes > maxCaptureBytes) {
164
+ kill("capture");
165
+ return;
166
+ }
167
+ stderrChunks.push(chunk);
168
+ });
169
+
170
+ child.on("error", (error) => {
171
+ for (const cleanup of cleanupFns) cleanup();
172
+ reject(error);
173
+ });
174
+
175
+ child.on("close", (code, signal) => {
176
+ closed = true;
177
+ for (const cleanup of cleanupFns) cleanup();
178
+
179
+ const stdout = sanitizeText(Buffer.concat(stdoutChunks).toString("utf8"));
180
+ const stderr = sanitizeText(Buffer.concat(stderrChunks).toString("utf8"));
181
+
182
+ if (exceededCapture) {
183
+ reject(
184
+ new Error(`forgejo-mcp output exceeded FORGEJO_MCP_MAX_CAPTURE_BYTES (${formatSize(maxCaptureBytes)}).`),
185
+ );
186
+ return;
187
+ }
188
+
189
+ resolve({ stdout, stderr, code, signal, timedOut });
190
+ });
191
+ });
192
+ }
193
+
194
+ export function ensureSuccess(result: CommandResult, context: string): void {
195
+ if (result.code === 0 && !result.timedOut) return;
196
+
197
+ const timeoutNote = result.timedOut ? " timed out" : " failed";
198
+ const stderr = truncateForError(result.stderr);
199
+ const stdout = truncateForError(result.stdout);
200
+ const pieces = [`forgejo-mcp ${context}${timeoutNote}`];
201
+ if (result.code !== null) pieces.push(`exit code: ${result.code}`);
202
+ if (result.signal) pieces.push(`signal: ${result.signal}`);
203
+ if (stderr) pieces.push(`stderr:\n${stderr}`);
204
+ if (stdout) pieces.push(`stdout:\n${stdout}`);
205
+ throw new Error(pieces.join("\n\n"));
206
+ }
207
+
208
+ export async function formatOutput(rawOutput: string, stderr: string): Promise<FormattedOutput> {
209
+ const trimmed = rawOutput.trim();
210
+ let output = trimmed || "OK (no output)";
211
+
212
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
213
+ try {
214
+ output = JSON.stringify(JSON.parse(trimmed), null, 2);
215
+ } catch {
216
+ output = trimmed;
217
+ }
218
+ }
219
+
220
+ const truncation = truncateHead(output, {
221
+ maxLines: DEFAULT_MAX_LINES,
222
+ maxBytes: DEFAULT_MAX_BYTES,
223
+ });
224
+
225
+ const details: FormattedOutput["details"] = {
226
+ truncated: truncation.truncated,
227
+ };
228
+
229
+ let text = truncation.content;
230
+
231
+ if (truncation.truncated) {
232
+ const dir = await mkdtemp(join(tmpdir(), "pi-forgejo-mcp-"));
233
+ const file = join(dir, "output.txt");
234
+ await writeFile(file, output, "utf8");
235
+
236
+ details.truncation = truncation;
237
+ details.fullOutputPath = file;
238
+
239
+ text += `\n\n[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`;
240
+ text += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
241
+ text += ` Full output saved to: ${file}]`;
242
+ }
243
+
244
+ const cleanedStderr = truncateForError(stderr, 2_000);
245
+ if (cleanedStderr) details.stderr = cleanedStderr;
246
+
247
+ return { text, details };
248
+ }
249
+
250
+ let toolsCache: ForgejoToolSummary[] | undefined;
251
+
252
+ export function clearToolsCache(): void {
253
+ toolsCache = undefined;
254
+ }
255
+
256
+ export async function listForgejoTools(signal?: AbortSignal): Promise<ForgejoToolSummary[]> {
257
+ if (toolsCache) return toolsCache;
258
+ const result = await runForgejo(["--cli", "list", "--output=json"], { env: discoveryEnv(), signal });
259
+ ensureSuccess(result, "tool list");
260
+
261
+ let parsed: unknown;
262
+ try {
263
+ parsed = JSON.parse(result.stdout);
264
+ } catch (error) {
265
+ throw new Error(`forgejo-mcp returned invalid tool list JSON: ${String(error)}`, { cause: error });
266
+ }
267
+
268
+ if (!Array.isArray(parsed)) {
269
+ throw new Error("forgejo-mcp returned an unexpected tool list shape.");
270
+ }
271
+
272
+ toolsCache = parsed
273
+ .map((item) => item as Record<string, unknown>)
274
+ .filter((item) => typeof item.name === "string")
275
+ .map((item) => ({
276
+ name: String(item.name),
277
+ description: typeof item.description === "string" ? item.description : undefined,
278
+ domain: typeof item.domain === "string" ? item.domain : undefined,
279
+ }))
280
+ .sort((a, b) => `${a.domain ?? ""}/${a.name}`.localeCompare(`${b.domain ?? ""}/${b.name}`));
281
+
282
+ return toolsCache;
283
+ }
284
+
285
+ export async function callForgejoTool(
286
+ tool: string,
287
+ args: Record<string, unknown> | undefined,
288
+ signal: AbortSignal | undefined,
289
+ ctx: ExtensionContext,
290
+ ) {
291
+ const validTool = validateToolName(tool);
292
+ await confirmMutationIfNeeded(ctx, validTool, args);
293
+
294
+ const result = await runForgejo(["--cli", validTool, "--args", JSON.stringify(args ?? {}), "--output=json"], {
295
+ env: requireRuntimeEnv(),
296
+ signal,
297
+ });
298
+ ensureSuccess(result, validTool);
299
+
300
+ const output = await formatOutput(result.stdout, result.stderr);
301
+ return {
302
+ content: [{ type: "text" as const, text: output.text }],
303
+ details: {
304
+ operation: validTool,
305
+ ...output.details,
306
+ },
307
+ };
308
+ }
@@ -0,0 +1,138 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "@earendil-works/pi-ai";
3
+ import {
4
+ callForgejoTool,
5
+ formatOutput,
6
+ getCommand,
7
+ listForgejoTools,
8
+ runForgejo,
9
+ discoveryEnv,
10
+ ensureSuccess,
11
+ truncateForError,
12
+ validateToolName,
13
+ } from "./cli.ts";
14
+
15
+ export default function forgejoMcpExtension(pi: ExtensionAPI) {
16
+ pi.registerTool({
17
+ name: "forgejo_mcp_status",
18
+ label: "Forgejo MCP Status",
19
+ description: "Check whether the forgejo-mcp CLI and required Forgejo environment variables are available.",
20
+ promptSnippet: "Check Forgejo MCP bridge configuration before using Forgejo tools.",
21
+ parameters: Type.Object({}),
22
+ async execute(_toolCallId, _params, signal) {
23
+ let cliAvailable = false;
24
+ let cliError: string | undefined;
25
+ let toolCount: number | undefined;
26
+ try {
27
+ const tools = await listForgejoTools(signal);
28
+ cliAvailable = true;
29
+ toolCount = tools.length;
30
+ } catch (error) {
31
+ cliError = error instanceof Error ? error.message : String(error);
32
+ }
33
+
34
+ const configured = Boolean(process.env.FORGEJO_URL && process.env.FORGEJO_ACCESS_TOKEN);
35
+ const lines = [
36
+ `forgejo-mcp command: ${getCommand()}`,
37
+ `CLI available: ${cliAvailable ? "yes" : "no"}`,
38
+ `FORGEJO_URL set: ${process.env.FORGEJO_URL ? "yes" : "no"}`,
39
+ `FORGEJO_ACCESS_TOKEN set: ${process.env.FORGEJO_ACCESS_TOKEN ? "yes" : "no"}`,
40
+ `Runtime configured: ${configured ? "yes" : "no"}`,
41
+ ];
42
+ if (toolCount !== undefined) lines.push(`Discovered tools: ${toolCount}`);
43
+ if (cliError) lines.push(`CLI error: ${truncateForError(cliError)}`);
44
+
45
+ return {
46
+ content: [{ type: "text", text: lines.join("\n") }],
47
+ details: { command: getCommand(), cliAvailable, configured, toolCount, cliError },
48
+ };
49
+ },
50
+ });
51
+
52
+ pi.registerTool({
53
+ name: "forgejo_mcp_list_tools",
54
+ label: "Forgejo MCP List Tools",
55
+ description: "List operations exposed by the official forgejo-mcp CLI. Output is truncated to 2000 lines or 50KB.",
56
+ promptSnippet: "List Forgejo MCP operations before calling an unfamiliar Forgejo operation.",
57
+ promptGuidelines: [
58
+ "Use forgejo_mcp_list_tools to discover Forgejo operation names before using forgejo_mcp_call when the exact operation is uncertain.",
59
+ ],
60
+ parameters: Type.Object({
61
+ domain: Type.Optional(
62
+ Type.String({
63
+ description: "Optional domain/category filter, such as repo, issue, pull, user, org, release, actions.",
64
+ }),
65
+ ),
66
+ query: Type.Optional(
67
+ Type.String({ description: "Optional case-insensitive substring filter for operation names or descriptions." }),
68
+ ),
69
+ }),
70
+ async execute(_toolCallId, params, signal) {
71
+ const domain = params.domain?.trim().toLowerCase();
72
+ const query = params.query?.trim().toLowerCase();
73
+ let tools = await listForgejoTools(signal);
74
+
75
+ if (domain) tools = tools.filter((tool) => tool.domain?.toLowerCase() === domain);
76
+ if (query) {
77
+ tools = tools.filter(
78
+ (tool) => tool.name.toLowerCase().includes(query) || (tool.description ?? "").toLowerCase().includes(query),
79
+ );
80
+ }
81
+
82
+ const output = await formatOutput(JSON.stringify(tools, null, 2), "");
83
+ return {
84
+ content: [{ type: "text", text: output.text }],
85
+ details: { count: tools.length, domain, query, ...output.details },
86
+ };
87
+ },
88
+ });
89
+
90
+ pi.registerTool({
91
+ name: "forgejo_mcp_describe_tool",
92
+ label: "Forgejo MCP Describe Tool",
93
+ description: "Show parameters and help for one forgejo-mcp operation.",
94
+ promptSnippet: "Describe a Forgejo MCP operation's required and optional arguments.",
95
+ promptGuidelines: [
96
+ "Use forgejo_mcp_describe_tool before forgejo_mcp_call when you need the argument schema for a Forgejo operation.",
97
+ ],
98
+ parameters: Type.Object({
99
+ tool: Type.String({ description: "forgejo-mcp operation name, for example list_repo_issues or create_issue." }),
100
+ }),
101
+ async execute(_toolCallId, params, signal) {
102
+ const tool = validateToolName(params.tool);
103
+ const result = await runForgejo(["--cli", tool, "--help"], { env: discoveryEnv(), signal });
104
+ ensureSuccess(result, `${tool} --help`);
105
+ const output = await formatOutput(result.stdout, result.stderr);
106
+ return {
107
+ content: [{ type: "text", text: output.text }],
108
+ details: { operation: tool, ...output.details },
109
+ };
110
+ },
111
+ });
112
+
113
+ pi.registerTool({
114
+ name: "forgejo_mcp_call",
115
+ label: "Forgejo MCP Call",
116
+ description:
117
+ "Invoke one operation from the official forgejo-mcp CLI. Requires FORGEJO_URL and FORGEJO_ACCESS_TOKEN in Pi's environment. Output is truncated to 2000 lines or 50KB.",
118
+ promptSnippet: "Call Forgejo operations via forgejo-mcp CLI using JSON arguments.",
119
+ promptGuidelines: [
120
+ "Use forgejo_mcp_call for Forgejo repository, issue, pull request, release, user, organization, workflow, and file operations.",
121
+ "Use forgejo_mcp_describe_tool before forgejo_mcp_call unless you already know the exact Forgejo operation arguments.",
122
+ "Do not put FORGEJO_ACCESS_TOKEN or other secrets in forgejo_mcp_call arguments; the extension reads credentials from the environment.",
123
+ ],
124
+ parameters: Type.Object({
125
+ tool: Type.String({
126
+ description: "forgejo-mcp operation name, for example list_my_repos, list_repo_issues, or create_issue.",
127
+ }),
128
+ args: Type.Optional(
129
+ Type.Record(Type.String(), Type.Any({ description: "JSON argument value passed through to forgejo-mcp." }), {
130
+ description: "JSON arguments for the operation. Use {} when the operation takes no arguments.",
131
+ }),
132
+ ),
133
+ }),
134
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
135
+ return callForgejoTool(params.tool, params.args as Record<string, unknown> | undefined, signal, ctx);
136
+ },
137
+ });
138
+ }
@@ -0,0 +1,58 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ const mutatingOperationPattern =
4
+ /^(add_|approve_|cancel_|create_|delete_|dismiss_|dispatch_|edit_|fork_|mark_|merge_|remove_|request_|reset_|start_|stop_|submit_|sync_|transfer_|update_|issue_state_change|pull_request_state_change)/;
5
+ const destructiveOperationPattern = /^(delete_|remove_|merge_|transfer_|cancel_|dismiss_|reset_)/;
6
+
7
+ export function truthy(value: string | undefined): boolean {
8
+ return value === "1" || value === "true" || value === "yes" || value === "on";
9
+ }
10
+
11
+ export function falsy(value: string | undefined): boolean {
12
+ return value === "0" || value === "false" || value === "no" || value === "off";
13
+ }
14
+
15
+ export function isMutatingOperation(tool: string): boolean {
16
+ return mutatingOperationPattern.test(tool);
17
+ }
18
+
19
+ export function isDestructiveOperation(tool: string): boolean {
20
+ return destructiveOperationPattern.test(tool);
21
+ }
22
+
23
+ export function summarizeTarget(args: Record<string, unknown> | undefined): string {
24
+ if (!args) return "";
25
+ const owner = typeof args.owner === "string" ? args.owner : undefined;
26
+ const repo = typeof args.repo === "string" ? args.repo : undefined;
27
+ const index = typeof args.index === "number" || typeof args.index === "string" ? `#${args.index}` : undefined;
28
+
29
+ const parts: string[] = [];
30
+ if (owner && repo) parts.push(`${owner}/${repo}`);
31
+ else if (repo) parts.push(repo);
32
+ else if (owner) parts.push(owner);
33
+ if (index) parts.push(index);
34
+
35
+ return parts.length > 0 ? ` (${parts.join(" ")})` : "";
36
+ }
37
+
38
+ export async function confirmMutationIfNeeded(
39
+ ctx: ExtensionContext,
40
+ tool: string,
41
+ args: Record<string, unknown> | undefined,
42
+ ): Promise<void> {
43
+ if (!isMutatingOperation(tool)) return;
44
+
45
+ if (ctx.hasUI && !falsy(process.env.FORGEJO_MCP_CONFIRM_MUTATIONS)) {
46
+ const severity = isDestructiveOperation(tool) ? "destructive Forgejo operation" : "Forgejo write operation";
47
+ const ok = await ctx.ui.confirm("Confirm Forgejo operation", `Allow ${severity}: ${tool}${summarizeTarget(args)}?`);
48
+ if (!ok) throw new Error(`Cancelled Forgejo operation: ${tool}`);
49
+ return;
50
+ }
51
+
52
+ if (!truthy(process.env.FORGEJO_MCP_ALLOW_MUTATIONS)) {
53
+ throw new Error(
54
+ `Refusing to run mutating Forgejo operation ${tool} without UI confirmation. ` +
55
+ "Set FORGEJO_MCP_ALLOW_MUTATIONS=true to allow this in non-interactive mode.",
56
+ );
57
+ }
58
+ }
@@ -0,0 +1 @@
1
+ export { default } from "./forgejo-mcp/index.ts";
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "pi-forgejo-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension that exposes Forgejo via the official forgejo-mcp CLI.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Ozzy",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://codeberg.org/ozzy92/pi-forgejo-mcp.git"
11
+ },
12
+ "homepage": "https://codeberg.org/ozzy92/pi-forgejo-mcp",
13
+ "bugs": {
14
+ "url": "https://codeberg.org/ozzy92/pi-forgejo-mcp/issues"
15
+ },
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi-extension",
19
+ "forgejo",
20
+ "mcp",
21
+ "model-context-protocol",
22
+ "codeberg"
23
+ ],
24
+ "files": [
25
+ "extensions",
26
+ "README.md",
27
+ "LICENSE",
28
+ "SECURITY.md",
29
+ "CONTRIBUTING.md",
30
+ "CHANGELOG.md"
31
+ ],
32
+ "pi": {
33
+ "extensions": [
34
+ "./extensions/forgejo-mcp/index.ts"
35
+ ]
36
+ },
37
+ "scripts": {
38
+ "typecheck": "tsc --noEmit",
39
+ "lint": "eslint .",
40
+ "format": "prettier --check . --ignore-unknown",
41
+ "format:write": "prettier --write . --ignore-unknown",
42
+ "test": "vitest run",
43
+ "check": "npm run typecheck && npm run lint && npm run format && npm run test"
44
+ },
45
+ "peerDependencies": {
46
+ "@earendil-works/pi-ai": "*",
47
+ "@earendil-works/pi-coding-agent": "*"
48
+ },
49
+ "devDependencies": {
50
+ "@earendil-works/pi-ai": "^0.77.0",
51
+ "@earendil-works/pi-coding-agent": "^0.77.0",
52
+ "@eslint/js": "^10.0.1",
53
+ "@types/node": "^24.0.0",
54
+ "eslint": "^10.4.0",
55
+ "globals": "^17.6.0",
56
+ "prettier": "^3.8.3",
57
+ "typescript": "^5.9.0",
58
+ "typescript-eslint": "^8.60.0",
59
+ "vitest": "^4.1.7"
60
+ },
61
+ "engines": {
62
+ "node": ">=22.19.0"
63
+ }
64
+ }