feat(mcp): add gitea_* tools so the AI can write code, not just deploy it

Closes the AI's self-reported gap: "I cannot directly commit or push code".

New MCP capabilities (8) — all scoped to the workspace's Gitea org via
requireGiteaOrg + ensureRepoOwnerInOrg:

- gitea.repos.list           — discover existing repos
- gitea.repo.get             — metadata (default branch, clone URL)
- gitea.repo.create          — mint a new private repo with auto-init
- gitea.file.read            — read a file (or list a directory)
- gitea.file.write           — create/update one file in one commit
- gitea.file.delete          — delete a file (auto-resolves sha)
- gitea.branches.list        — list branches with head sha
- gitea.branch.create        — branch off an existing branch

Wired through:
- lib/gitea.ts: giteaReadFile, giteaListContents, giteaListBranches,
  giteaCreateBranch, giteaListOrgRepos, giteaDeleteFile.
- lib/ai/vibn-tools.ts: 8 new Gemini tool declarations (53 total).
- app/api/chat/route.ts: system prompt now teaches the end-to-end
  scaffold-then-deploy recipe so the AI stops deferring to the user.

MCP capability descriptor bumped to version 2.5.0.

Made-with: Cursor
This commit is contained in:
2026-04-28 11:52:16 -07:00
parent 2de3c5ce57
commit c8dec7c656
4 changed files with 448 additions and 2 deletions

View File

@@ -95,6 +95,19 @@ You are talking to the owner of the "${workspace}" workspace.
2. \`domains_register { domain }\` to buy it (uses workspace billing).
3. \`apps_domains_set { uuid, domains }\` to attach. DNS + Traefik are wired automatically.
## Writing & shipping code (Gitea)
You CAN write code directly — don't tell the user "I can only generate code, you push it." Use these tools to scaffold and edit a project's repo end-to-end:
- \`gitea_repo_create { name }\` — mint a new private repo in the workspace org.
- \`gitea_file_write { repo, path, content, message }\` — commit one file at a time. Call repeatedly to scaffold a project.
- \`gitea_file_read { repo, path }\` — inspect existing code (returns directory listings if path is a folder).
- \`gitea_branches_list\` / \`gitea_branch_create\` — branch for risky edits.
- \`gitea_repos_list\` — discover what already exists before creating anything new.
End-to-end recipe for "build me X":
1. \`gitea_repo_create { name: 'x' }\`.
2. \`gitea_file_write\` × N — package.json, Dockerfile, src/index.ts, etc.
3. \`apps_create { projectId, repo: 'x', ports, domain }\` — Pathway 1 deploys from the Gitea repo. Coolify auto-redeploys on subsequent file writes.
## Troubleshooting
- Deploy stuck or "exited (1)" → \`apps_logs { uuid }\` and \`apps_containers_list { uuid }\`. Common causes: missing env var, wrong port, image pull failure.
- 502 / "no available server" → app probably has no public domain yet. Check \`apps_get\`; if \`fqdn\` is empty, attach a domain.

View File

@@ -83,7 +83,17 @@ import {
type CoolifyDatabaseType,
} from '@/lib/coolify';
import { query, queryOne } from '@/lib/db-postgres';
import { getRepo } from '@/lib/gitea';
import {
getRepo,
createRepo,
giteaPushFile,
giteaReadFile,
giteaListContents,
giteaListBranches,
giteaCreateBranch,
giteaListOrgRepos,
giteaDeleteFile,
} from '@/lib/gitea';
import {
giteaHttpsUrl,
isDomainUnderWorkspace,
@@ -101,7 +111,7 @@ const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
export async function GET() {
return NextResponse.json({
name: 'vibn-mcp',
version: '2.4.8',
version: '2.5.0',
authentication: {
scheme: 'Bearer',
tokenPrefix: 'vibn_sk_',
@@ -155,6 +165,14 @@ export async function GET() {
'storage.describe',
'storage.provision',
'storage.inject_env',
'gitea.repos.list',
'gitea.repo.get',
'gitea.repo.create',
'gitea.file.read',
'gitea.file.write',
'gitea.file.delete',
'gitea.branches.list',
'gitea.branch.create',
],
},
},
@@ -283,6 +301,23 @@ export async function POST(request: Request) {
case 'storage.inject_env':
return await toolStorageInjectEnv(principal, params);
case 'gitea.repos.list':
return await toolGiteaReposList(principal);
case 'gitea.repo.get':
return await toolGiteaRepoGet(principal, params);
case 'gitea.repo.create':
return await toolGiteaRepoCreate(principal, params);
case 'gitea.file.read':
return await toolGiteaFileRead(principal, params);
case 'gitea.file.write':
return await toolGiteaFileWrite(principal, params);
case 'gitea.file.delete':
return await toolGiteaFileDelete(principal, params);
case 'gitea.branches.list':
return await toolGiteaBranchesList(principal, params);
case 'gitea.branch.create':
return await toolGiteaBranchCreate(principal, params);
default:
return NextResponse.json(
{ error: `Unknown tool "${action}"` },
@@ -2544,3 +2579,191 @@ async function toolStorageInjectEnv(principal: Principal, params: Record<string,
},
});
}
// ──────────────────────────────────────────────────
// Gitea — repos & file CRUD (lets the AI write code, not just deploy it)
// ──────────────────────────────────────────────────
//
// Tenant safety: every operation is scoped to `principal.workspace.gitea_org`.
// A workspace can never read or write into another workspace's repos because
// requireGiteaOrg() rejects any path whose owner isn't the caller's org.
function requireGiteaOrg(principal: Principal): string | NextResponse {
const org = principal.workspace.gitea_org;
if (!org) {
return NextResponse.json(
{ error: 'Workspace has no Gitea org yet (provisioning incomplete)' },
{ status: 503 },
);
}
return org;
}
function ensureRepoOwnerInOrg(
ownerParam: unknown,
org: string,
): string | NextResponse {
const owner = typeof ownerParam === 'string' && ownerParam.length > 0 ? ownerParam : org;
if (owner !== org) {
return NextResponse.json(
{ error: `owner "${owner}" is outside this workspace's org "${org}"` },
{ status: 403 },
);
}
return owner;
}
async function toolGiteaReposList(principal: Principal) {
const org = requireGiteaOrg(principal);
if (org instanceof NextResponse) return org;
const repos = await giteaListOrgRepos(org);
return NextResponse.json({
result: repos.map(r => ({
name: r.name,
fullName: r.full_name,
defaultBranch: r.default_branch,
cloneUrl: r.clone_url,
htmlUrl: r.html_url,
private: r.private,
})),
});
}
async function toolGiteaRepoGet(principal: Principal, params: Record<string, any>) {
const org = requireGiteaOrg(principal);
if (org instanceof NextResponse) return org;
const owner = ensureRepoOwnerInOrg(params.owner, org);
if (owner instanceof NextResponse) return owner;
const name = String(params.repo ?? params.name ?? '').trim();
if (!name) return NextResponse.json({ error: 'Param "repo" is required' }, { status: 400 });
const r = await getRepo(owner, name);
if (!r) return NextResponse.json({ error: `Repo ${owner}/${name} not found` }, { status: 404 });
return NextResponse.json({
result: {
name: r.name,
fullName: r.full_name,
defaultBranch: r.default_branch,
cloneUrl: r.clone_url,
htmlUrl: r.html_url,
private: r.private,
},
});
}
async function toolGiteaRepoCreate(principal: Principal, params: Record<string, any>) {
const org = requireGiteaOrg(principal);
if (org instanceof NextResponse) return org;
const name = slugify(String(params.name ?? '').trim());
if (!name) return NextResponse.json({ error: 'Param "name" is required' }, { status: 400 });
const repo = await createRepo(name, {
owner: org,
description: params.description ? String(params.description) : undefined,
private: params.private !== false,
auto_init: params.autoInit !== false,
});
return NextResponse.json({
result: {
name: repo.name,
fullName: repo.full_name,
defaultBranch: repo.default_branch,
cloneUrl: repo.clone_url,
htmlUrl: repo.html_url,
},
});
}
async function toolGiteaFileRead(principal: Principal, params: Record<string, any>) {
const org = requireGiteaOrg(principal);
if (org instanceof NextResponse) return org;
const owner = ensureRepoOwnerInOrg(params.owner, org);
if (owner instanceof NextResponse) return owner;
const repo = String(params.repo ?? '').trim();
const path = String(params.path ?? '').trim();
if (!repo || !path) {
return NextResponse.json({ error: 'Params "repo" and "path" are required' }, { status: 400 });
}
const ref = params.ref ? String(params.ref) : undefined;
// If path is a directory, list contents instead. The contents API returns
// an array for dirs and an object for files.
try {
const file = await giteaReadFile(owner, repo, path, ref);
return NextResponse.json({ result: { type: 'file', ...file } });
} catch (err: any) {
// Probably a directory — fall back to listing.
try {
const items = await giteaListContents(owner, repo, path, ref);
return NextResponse.json({ result: { type: 'directory', items } });
} catch {
return NextResponse.json(
{ error: err?.message || `Path ${path} not found in ${owner}/${repo}` },
{ status: 404 },
);
}
}
}
async function toolGiteaFileWrite(principal: Principal, params: Record<string, any>) {
const org = requireGiteaOrg(principal);
if (org instanceof NextResponse) return org;
const owner = ensureRepoOwnerInOrg(params.owner, org);
if (owner instanceof NextResponse) return owner;
const repo = String(params.repo ?? '').trim();
const path = String(params.path ?? '').trim();
const content = typeof params.content === 'string' ? params.content : '';
const message = String(params.message ?? `Update ${path}`).trim();
const branch = String(params.branch ?? 'main').trim();
if (!repo || !path) {
return NextResponse.json({ error: 'Params "repo" and "path" are required' }, { status: 400 });
}
await giteaPushFile(owner, repo, path, content, message, branch);
return NextResponse.json({
result: { ok: true, owner, repo, path, branch, bytes: Buffer.byteLength(content, 'utf-8') },
});
}
async function toolGiteaFileDelete(principal: Principal, params: Record<string, any>) {
const org = requireGiteaOrg(principal);
if (org instanceof NextResponse) return org;
const owner = ensureRepoOwnerInOrg(params.owner, org);
if (owner instanceof NextResponse) return owner;
const repo = String(params.repo ?? '').trim();
const path = String(params.path ?? '').trim();
const branch = String(params.branch ?? 'main').trim();
const message = String(params.message ?? `Delete ${path}`).trim();
if (!repo || !path) {
return NextResponse.json({ error: 'Params "repo" and "path" are required' }, { status: 400 });
}
// Need the file's current sha to delete; fetch it first.
const file = await giteaReadFile(owner, repo, path, branch).catch(() => null);
if (!file) return NextResponse.json({ error: `${path} not found` }, { status: 404 });
await giteaDeleteFile(owner, repo, path, file.sha, message, branch);
return NextResponse.json({ result: { ok: true, owner, repo, path, branch } });
}
async function toolGiteaBranchesList(principal: Principal, params: Record<string, any>) {
const org = requireGiteaOrg(principal);
if (org instanceof NextResponse) return org;
const owner = ensureRepoOwnerInOrg(params.owner, org);
if (owner instanceof NextResponse) return owner;
const repo = String(params.repo ?? '').trim();
if (!repo) return NextResponse.json({ error: 'Param "repo" is required' }, { status: 400 });
const branches = await giteaListBranches(owner, repo);
return NextResponse.json({
result: branches.map(b => ({ name: b.name, sha: b.commit?.id, protected: b.protected })),
});
}
async function toolGiteaBranchCreate(principal: Principal, params: Record<string, any>) {
const org = requireGiteaOrg(principal);
if (org instanceof NextResponse) return org;
const owner = ensureRepoOwnerInOrg(params.owner, org);
if (owner instanceof NextResponse) return owner;
const repo = String(params.repo ?? '').trim();
const name = String(params.name ?? params.branch ?? '').trim();
const fromBranch = params.from ? String(params.from) : undefined;
if (!repo || !name) {
return NextResponse.json({ error: 'Params "repo" and "name" are required' }, { status: 400 });
}
const b = await giteaCreateBranch(owner, repo, name, fromBranch);
return NextResponse.json({ result: { name: b.name, sha: b.commit?.id } });
}

View File

@@ -518,6 +518,125 @@ Auto-domain {name}.{workspace}.vibnai.com is assigned automatically.`,
},
},
// ── Gitea — repos & file CRUD (write code, not just deploy it) ────────────
//
// All gitea_* tools are scoped to the workspace's Gitea org. The AI can
// create repos, read & write files, and manage branches inside its own
// org but never outside it (enforced by requireGiteaOrg + ensureRepoOwnerInOrg
// in the MCP route).
{
name: 'gitea_repos_list',
description:
'List every Gitea repo in the workspace org. Use to discover repos already provisioned for projects.',
parameters: { type: 'OBJECT', properties: {}, required: [] },
},
{
name: 'gitea_repo_get',
description:
'Get metadata for a single Gitea repo (default branch, clone URL, html URL, private flag).',
parameters: {
type: 'OBJECT',
properties: {
repo: { type: 'STRING', description: 'Repo name (without org prefix).' },
owner: { type: 'STRING', description: 'Optional org/user. Defaults to the workspace Gitea org.' },
},
required: ['repo'],
},
},
{
name: 'gitea_repo_create',
description:
'Create a new Gitea repo inside the workspace org. By default initializes with a README and is private. ' +
'Use this when scaffolding a new app the AI is going to write code for and then deploy via apps_create({ repo }).',
parameters: {
type: 'OBJECT',
properties: {
name: { type: 'STRING', description: 'Repo name. Will be slugified (lowercase, hyphens).' },
description: { type: 'STRING', description: 'Optional repo description.' },
private: { type: 'BOOLEAN', description: 'Whether the repo is private (default true).' },
autoInit: { type: 'BOOLEAN', description: 'Initialize with README (default true). Set false if writing files immediately yourself.' },
},
required: ['name'],
},
},
{
name: 'gitea_file_read',
description:
'Read a single file from a workspace Gitea repo, OR list directory contents if path is a directory. ' +
'Returns { type: "file", content, sha, size, encoding } or { type: "directory", items: [...] }.',
parameters: {
type: 'OBJECT',
properties: {
repo: { type: 'STRING', description: 'Repo name.' },
path: { type: 'STRING', description: 'File or directory path within the repo.' },
ref: { type: 'STRING', description: 'Branch, tag, or commit SHA (default: repo default branch).' },
owner: { type: 'STRING', description: 'Optional org. Defaults to the workspace Gitea org.' },
},
required: ['repo', 'path'],
},
},
{
name: 'gitea_file_write',
description:
'Create or update a single file in a workspace Gitea repo (one commit per call). Use to scaffold or patch code: ' +
'package.json, Dockerfile, src/server.ts, README.md, etc. Always include a meaningful commit message.',
parameters: {
type: 'OBJECT',
properties: {
repo: { type: 'STRING', description: 'Repo name.' },
path: { type: 'STRING', description: 'File path within the repo (e.g. "src/index.ts" or "Dockerfile").' },
content: { type: 'STRING', description: 'Full new file content as a UTF-8 string.' },
message: { type: 'STRING', description: 'Commit message. Default: "Update {path}".' },
branch: { type: 'STRING', description: 'Target branch (default "main"). Branch must already exist.' },
owner: { type: 'STRING', description: 'Optional org. Defaults to the workspace Gitea org.' },
},
required: ['repo', 'path', 'content'],
},
},
{
name: 'gitea_file_delete',
description: 'Delete a single file from a workspace Gitea repo (one commit). Resolves the file sha automatically.',
parameters: {
type: 'OBJECT',
properties: {
repo: { type: 'STRING', description: 'Repo name.' },
path: { type: 'STRING', description: 'File path within the repo.' },
message: { type: 'STRING', description: 'Commit message. Default: "Delete {path}".' },
branch: { type: 'STRING', description: 'Target branch (default "main").' },
owner: { type: 'STRING', description: 'Optional org. Defaults to the workspace Gitea org.' },
},
required: ['repo', 'path'],
},
},
{
name: 'gitea_branches_list',
description: 'List all branches of a workspace Gitea repo with their head SHA.',
parameters: {
type: 'OBJECT',
properties: {
repo: { type: 'STRING', description: 'Repo name.' },
owner: { type: 'STRING', description: 'Optional org. Defaults to the workspace Gitea org.' },
},
required: ['repo'],
},
},
{
name: 'gitea_branch_create',
description:
'Create a new branch in a workspace Gitea repo, branched off an existing one (default = repo default branch).',
parameters: {
type: 'OBJECT',
properties: {
repo: { type: 'STRING', description: 'Repo name.' },
name: { type: 'STRING', description: 'New branch name.' },
from: { type: 'STRING', description: 'Existing branch to branch from (default: repo default branch).' },
owner: { type: 'STRING', description: 'Optional org. Defaults to the workspace Gitea org.' },
},
required: ['repo', 'name'],
},
},
// ── Non-MCP: GitHub & web ─────────────────────────────────────────────────
{

View File

@@ -521,4 +521,95 @@ export async function giteaPushFile(
});
}
/**
* Read a file from a Gitea repo. Returns decoded content + sha.
* Throws if path is missing.
*/
export async function giteaReadFile(
owner: string,
repo: string,
path: string,
ref?: string,
): Promise<{ content: string; sha: string; size: number; encoding: string }> {
const qs = ref ? `?ref=${encodeURIComponent(ref)}` : '';
const data = await giteaFetch(`/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}${qs}`);
const d = data as any;
const raw = d.content && d.encoding === 'base64'
? Buffer.from(d.content, 'base64').toString('utf-8')
: (d.content ?? '');
return { content: raw, sha: d.sha, size: d.size ?? 0, encoding: d.encoding ?? 'utf-8' };
}
/** List files/folders at a path inside a Gitea repo. */
export async function giteaListContents(
owner: string,
repo: string,
path = '',
ref?: string,
): Promise<Array<{ name: string; path: string; type: string; size: number; sha: string }>> {
const qs = ref ? `?ref=${encodeURIComponent(ref)}` : '';
const data = await giteaFetch(`/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}${qs}`);
const arr = Array.isArray(data) ? data : [data];
return arr.map((d: any) => ({
name: d.name,
path: d.path,
type: d.type,
size: d.size ?? 0,
sha: d.sha,
}));
}
export interface GiteaBranch {
name: string;
commit: { id: string; message?: string };
protected: boolean;
}
export async function giteaListBranches(owner: string, repo: string): Promise<GiteaBranch[]> {
const data = await giteaFetch(`/repos/${owner}/${repo}/branches`);
return Array.isArray(data) ? (data as GiteaBranch[]) : [];
}
/** Create a new branch from an existing one (defaults to repo's default branch). */
export async function giteaCreateBranch(
owner: string,
repo: string,
newBranch: string,
fromBranch?: string,
): Promise<GiteaBranch> {
return giteaFetch(`/repos/${owner}/${repo}/branches`, {
method: 'POST',
body: JSON.stringify({
new_branch_name: newBranch,
...(fromBranch ? { old_branch_name: fromBranch } : {}),
}),
}) as Promise<GiteaBranch>;
}
/** List repos owned by an org (or user). */
export async function giteaListOrgRepos(orgOrUser: string): Promise<GiteaRepo[]> {
const data = await giteaFetch(`/orgs/${orgOrUser}/repos`).catch(async () => {
// If it's not an org, try the user endpoint.
return giteaFetch(`/users/${orgOrUser}/repos`);
});
return Array.isArray(data) ? (data as GiteaRepo[]) : [];
}
/**
* Delete a single file at path. Requires the file's current sha.
*/
export async function giteaDeleteFile(
owner: string,
repo: string,
path: string,
sha: string,
message: string,
branch = 'main',
): Promise<void> {
await giteaFetch(`/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`, {
method: 'DELETE',
body: JSON.stringify({ message, sha, branch }),
});
}
export const GITEA_ADMIN_USER_EXPORT = GITEA_ADMIN_USER;