gitea-mcp/src/index.ts

517 lines
15 KiB
JavaScript

#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ErrorCode,
ListToolsRequestSchema,
McpError,
} from '@modelcontextprotocol/sdk/types.js';
import axios, { AxiosInstance } from 'axios';
interface RepoArgs {
owner: string;
repo: string;
}
interface CreateRepoArgs {
name: string;
description?: string;
private?: boolean;
autoInit?: boolean;
}
interface CreateFileArgs extends RepoArgs {
path: string;
content?: string;
file_text?: string; // Alternative parameter name for content
message: string;
branch?: string;
}
interface GetContentsArgs extends RepoArgs {
path: string;
ref?: string;
}
interface CreateBranchArgs extends RepoArgs {
branch: string;
from?: string;
}
interface IssueArgs extends RepoArgs {
state?: 'open' | 'closed';
}
interface CreateIssueArgs extends RepoArgs {
title: string;
body: string;
}
interface PullRequestArgs extends RepoArgs {
state?: 'open' | 'closed' | 'all';
}
const GITEA_TOKEN = process.env.GITEA_TOKEN;
const GITEA_API_URL = process.env.GITEA_API_URL;
if (!GITEA_TOKEN) {
throw new Error('GITEA_TOKEN environment variable is required');
}
if (!GITEA_API_URL) {
throw new Error('GITEA_API_URL environment variable is required');
}
class GiteaServer {
private server: Server;
private api: AxiosInstance;
constructor() {
this.server = new Server(
{
name: 'gitea-server',
version: '0.1.0',
},
{
capabilities: {
tools: {},
},
}
);
this.api = axios.create({
baseURL: GITEA_API_URL,
headers: {
'Authorization': `token ${GITEA_TOKEN}`,
'Accept': 'application/json',
},
});
this.setupToolHandlers();
this.server.onerror = (error) => console.error('[MCP Error]', error);
process.on('SIGINT', async () => {
await this.server.close();
process.exit(0);
});
}
private setupToolHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'list_repositories',
description: 'List repositories for the authenticated user',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'get_repository',
description: 'Get details about a specific repository',
inputSchema: {
type: 'object',
properties: {
owner: {
type: 'string',
description: 'Repository owner',
},
repo: {
type: 'string',
description: 'Repository name',
},
},
required: ['owner', 'repo'],
},
},
{
name: 'list_issues',
description: 'List issues in a repository',
inputSchema: {
type: 'object',
properties: {
owner: {
type: 'string',
description: 'Repository owner',
},
repo: {
type: 'string',
description: 'Repository name',
},
state: {
type: 'string',
description: 'Issue state (open/closed)',
enum: ['open', 'closed'],
},
},
required: ['owner', 'repo'],
},
},
{
name: 'create_issue',
description: 'Create a new issue in a repository',
inputSchema: {
type: 'object',
properties: {
owner: {
type: 'string',
description: 'Repository owner',
},
repo: {
type: 'string',
description: 'Repository name',
},
title: {
type: 'string',
description: 'Issue title',
},
body: {
type: 'string',
description: 'Issue body/description',
},
},
required: ['owner', 'repo', 'title', 'body'],
},
},
{
name: 'list_pull_requests',
description: 'List pull requests in a repository',
inputSchema: {
type: 'object',
properties: {
owner: {
type: 'string',
description: 'Repository owner',
},
repo: {
type: 'string',
description: 'Repository name',
},
state: {
type: 'string',
description: 'PR state (open/closed/all)',
enum: ['open', 'closed', 'all'],
},
},
required: ['owner', 'repo'],
},
},
{
name: 'create_repository',
description: 'Create a new repository',
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Repository name',
},
description: {
type: 'string',
description: 'Repository description',
},
private: {
type: 'boolean',
description: 'Whether the repository is private',
},
autoInit: {
type: 'boolean',
description: 'Whether to initialize with README',
},
},
required: ['name'],
},
},
{
name: 'get_contents',
description: 'Get contents of a file or directory in a repository',
inputSchema: {
type: 'object',
properties: {
owner: {
type: 'string',
description: 'Repository owner',
},
repo: {
type: 'string',
description: 'Repository name',
},
path: {
type: 'string',
description: 'Path to file or directory',
},
ref: {
type: 'string',
description: 'Git reference (branch/tag/commit)',
},
},
required: ['owner', 'repo', 'path'],
},
},
{
name: 'add_file_to_repo',
description: 'Add a new file to a repository (supports both "content" and "file_text" parameters)',
inputSchema: {
type: 'object',
properties: {
owner: {
type: 'string',
description: 'Repository owner',
},
repo: {
type: 'string',
description: 'Repository name',
},
path: {
type: 'string',
description: 'Path to file',
},
content: {
type: 'string',
description: 'File content (will be base64 encoded). Either content or file_text must be provided.',
},
file_text: {
type: 'string',
description: 'Alternative parameter for file content. Either content or file_text must be provided.',
},
message: {
type: 'string',
description: 'Commit message',
},
branch: {
type: 'string',
description: 'Branch name',
},
},
required: ['owner', 'repo', 'path', 'message'],
},
},
{
name: 'create_branch',
description: 'Create a new branch in a repository',
inputSchema: {
type: 'object',
properties: {
owner: {
type: 'string',
description: 'Repository owner',
},
repo: {
type: 'string',
description: 'Repository name',
},
branch: {
type: 'string',
description: 'New branch name',
},
from: {
type: 'string',
description: 'Base branch/commit to create from (defaults to default branch)',
},
},
required: ['owner', 'repo', 'branch'],
},
},
],
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
switch (request.params.name) {
case 'list_repositories': {
const response = await this.api.get('/user/repos');
return {
content: [
{
type: 'text',
text: JSON.stringify(response.data, null, 2),
},
],
};
}
case 'get_repository': {
const { owner, repo } = request.params.arguments as unknown as RepoArgs;
const response = await this.api.get(`/repos/${owner}/${repo}`);
return {
content: [
{
type: 'text',
text: JSON.stringify(response.data, null, 2),
},
],
};
}
case 'list_issues': {
const { owner, repo, state } = request.params.arguments as unknown as IssueArgs;
const response = await this.api.get(`/repos/${owner}/${repo}/issues`, {
params: { state },
});
return {
content: [
{
type: 'text',
text: JSON.stringify(response.data, null, 2),
},
],
};
}
case 'create_issue': {
const { owner, repo, title, body } = request.params.arguments as unknown as CreateIssueArgs;
const response = await this.api.post(`/repos/${owner}/${repo}/issues`, {
title,
body,
});
return {
content: [
{
type: 'text',
text: JSON.stringify(response.data, null, 2),
},
],
};
}
case 'list_pull_requests': {
const { owner, repo, state } = request.params.arguments as unknown as PullRequestArgs;
const response = await this.api.get(`/repos/${owner}/${repo}/pulls`, {
params: { state },
});
return {
content: [
{
type: 'text',
text: JSON.stringify(response.data, null, 2),
},
],
};
}
case 'create_repository': {
const { name, description, private: isPrivate, autoInit } = request.params.arguments as unknown as CreateRepoArgs;
const response = await this.api.post('/user/repos', {
name,
description,
private: isPrivate,
auto_init: autoInit,
});
return {
content: [
{
type: 'text',
text: JSON.stringify(response.data, null, 2),
},
],
};
}
case 'get_contents': {
const { owner, repo, path, ref } = request.params.arguments as unknown as GetContentsArgs;
const response = await this.api.get(`/repos/${owner}/${repo}/contents/${path}`, {
params: { ref },
});
return {
content: [
{
type: 'text',
text: JSON.stringify(response.data, null, 2),
},
],
};
}
case 'add_file_to_repo': {
const args = request.params.arguments as unknown as CreateFileArgs;
// Support both 'content' and 'file_text' for backward compatibility
const fileContent = args.content || (args as any).file_text;
if (!fileContent) {
throw new McpError(
ErrorCode.InvalidParams,
'Either "content" or "file_text" parameter is required'
);
}
const response = await this.api.post(`/repos/${args.owner}/${args.repo}/contents/${args.path}`, {
content: Buffer.from(fileContent).toString('base64'),
message: args.message,
branch: args.branch,
});
// Verify the file exists after creation
const verifyResponse = await this.api.get(`/repos/${args.owner}/${args.repo}/contents/${args.path}`);
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
message: 'File created successfully',
file: {
path: args.path,
url: response.data.content.html_url,
commit: response.data.commit,
},
content: verifyResponse.data,
}, null, 2),
},
],
};
}
case 'create_branch': {
const { owner, repo, branch, from } = request.params.arguments as unknown as CreateBranchArgs;
// Get the default branch if 'from' is not specified
const sourceBranch = from || (await this.api.get(`/repos/${owner}/${repo}`)).data.default_branch;
// Get the commit SHA from the source branch
const branchResponse = await this.api.get(`/repos/${owner}/${repo}/branches/${sourceBranch}`);
const sha = branchResponse.data.commit.id;
// Create the new branch
const response = await this.api.post(`/repos/${owner}/${repo}/branches`, {
new_branch_name: branch,
old_branch_name: sourceBranch
});
return {
content: [
{
type: 'text',
text: JSON.stringify(response.data, null, 2),
},
],
};
}
default:
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${request.params.name}`
);
}
} catch (error) {
if (axios.isAxiosError(error)) {
throw new McpError(
ErrorCode.InternalError,
`Gitea API error: ${error.response?.data?.message ?? error.message}`
);
}
throw error;
}
});
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('Gitea MCP server running on stdio');
}
}
const server = new GiteaServer();
server.run().catch(console.error);