diff --git a/.changeset/connector-github.md b/.changeset/connector-github.md new file mode 100644 index 0000000..308dc18 --- /dev/null +++ b/.changeset/connector-github.md @@ -0,0 +1,5 @@ +--- +"@gemstack/connector-github": minor +--- + +New package: the GitHub connector for GemStack AI orchestration. Read and act on issues, pull requests, and repository files over the GitHub REST API — `get-repo`, `list-issues`, `get-issue`, `list-pull-requests`, `get-pull-request`, `get-file`, `search-issues`, `comment-on-issue`, `create-issue`. Built with `@gemstack/connectors`; consumes a bearer token (PAT or OAuth) via the mount `credentials` seam. First real connector on the contract (epic #86). diff --git a/packages/connector-github/README.md b/packages/connector-github/README.md new file mode 100644 index 0000000..0b5e47b --- /dev/null +++ b/packages/connector-github/README.md @@ -0,0 +1,48 @@ +# @gemstack/connector-github + +A GitHub connector for GemStack AI orchestration. Read and act on issues, pull requests, and repository files over the GitHub REST API. Built with [`@gemstack/connectors`](../connectors); the result is a standard `@gemstack/mcp` server. + +## Install + +```bash +npm i @gemstack/connector-github @gemstack/connectors @gemstack/mcp +``` + +## Use + +```ts +import { mountConnectors } from '@gemstack/connectors' +import { Mcp } from '@gemstack/mcp' +import github from '@gemstack/connector-github' + +const Server = mountConnectors([github], { + // A token with `repo` scope. A PAT or an OAuth bearer both work. + credentials: () => ({ token: process.env.GITHUB_TOKEN }), +}) + +Mcp.web('/mcp/github', Server) +``` + +Tools are exposed namespaced by connector id, e.g. `github_list-issues`. + +## Auth + +The connector declares `auth: { type: 'pat', env: 'GITHUB_TOKEN' }`. It only consumes a bearer token from `ctx.auth.token` — it does no OAuth handshake itself. To protect a web endpoint with OAuth 2.1, wrap it with `@gemstack/mcp`'s `oauth2McpMiddleware` + `registerOAuth2Metadata` and feed the verified token through the mount `credentials` option. + +## Tools + +| Tool | Kind | Description | +|---|---|---| +| `get-repo` | read | Repository metadata | +| `list-issues` | read | Issues (excludes PRs) | +| `get-issue` | read | One issue by number | +| `list-pull-requests` | read | Pull requests | +| `get-pull-request` | read | One PR by number | +| `get-file` | read | File contents (base64-decoded) | +| `search-issues` | read | Search issues/PRs (GitHub search syntax) | +| `comment-on-issue` | write | Comment on an issue or PR | +| `create-issue` | write | Open a new issue | + +Read tools are annotated `readOnly` so an agent can auto-approve them; write tools are not. + +Responses are slimmed to the fields an agent needs (no raw API envelopes) to keep token usage down. diff --git a/packages/connector-github/package.json b/packages/connector-github/package.json new file mode 100644 index 0000000..687771f --- /dev/null +++ b/packages/connector-github/package.json @@ -0,0 +1,57 @@ +{ + "name": "@gemstack/connector-github", + "version": "0.0.0", + "description": "GitHub connector for GemStack AI orchestration: read and act on issues, pull requests, and repo files over the GitHub REST API. Built with @gemstack/connectors.", + "keywords": [ + "mcp", + "connector", + "github", + "ai", + "agents", + "orchestration", + "gemstack" + ], + "license": "MIT", + "homepage": "https://github.com/gemstack-land/gemstack/tree/main/packages/connector-github#readme", + "bugs": { + "url": "https://github.com/gemstack-land/gemstack/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/gemstack-land/gemstack", + "directory": "packages/connector-github" + }, + "type": "module", + "engines": { + "node": ">=22.12.0" + }, + "files": [ + "dist" + ], + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "dev": "tsc -p tsconfig.build.json --watch", + "typecheck": "tsc --noEmit", + "test": "tsc -p tsconfig.test.json && cd dist-test && node --test", + "clean": "rm -rf dist dist-test" + }, + "dependencies": { + "@gemstack/connectors": "workspace:^", + "@gemstack/mcp": "workspace:^", + "zod": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "reflect-metadata": "^0.2.0", + "typescript": "^5.4.0" + }, + "author": "Suleiman Shahbari" +} diff --git a/packages/connector-github/src/client.ts b/packages/connector-github/src/client.ts new file mode 100644 index 0000000..6607ff7 --- /dev/null +++ b/packages/connector-github/src/client.ts @@ -0,0 +1,52 @@ +import type { ConnectorContext } from '@gemstack/connectors' + +const API = 'https://api.github.com' + +/** Thrown when the GitHub API returns a non-2xx response. */ +export class GitHubError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message) + this.name = 'GitHubError' + } +} + +/** + * Minimal GitHub REST client over global `fetch`. The bearer token comes from + * the connector context (`ctx.auth.token`) — a PAT or an OAuth token; GitHub + * accepts either as `Authorization: Bearer`. No SDK dependency, so the connector + * stays light. + */ +export async function gh( + ctx: ConnectorContext, + method: string, + path: string, + body?: unknown, +): Promise { + const token = ctx.auth.token + if (!token) { + throw new GitHubError( + 401, + 'no GitHub token available — set GITHUB_TOKEN or provide one via the mount `credentials` option', + ) + } + const res = await fetch(`${API}${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'gemstack-connector-github', + ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }) + if (!res.ok) { + const detail = await res.text().catch(() => '') + throw new GitHubError(res.status, `${method} ${path} -> ${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`) + } + if (res.status === 204) return undefined as T + return (await res.json()) as T +} diff --git a/packages/connector-github/src/index.test.ts b/packages/connector-github/src/index.test.ts new file mode 100644 index 0000000..315b45c --- /dev/null +++ b/packages/connector-github/src/index.test.ts @@ -0,0 +1,121 @@ +import 'reflect-metadata' +import { test, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { McpTestClient } from '@gemstack/mcp/testing' +import type { McpToolResult } from '@gemstack/mcp' +import { mountConnectors } from '@gemstack/connectors' +import github from './index.js' + +const realFetch = globalThis.fetch + +interface Captured { + url: string + method: string + body?: unknown +} +let calls: Captured[] = [] + +/** Install a fetch stub. `handler` maps a request to `{ status?, body }`. */ +function mockFetch(handler: (url: string, method: string) => { status?: number; body: unknown }) { + globalThis.fetch = (async (url: string | URL, init: RequestInit = {}) => { + const method = init.method ?? 'GET' + calls.push({ url: String(url), method, body: init.body ? JSON.parse(String(init.body)) : undefined }) + const { status = 200, body } = handler(String(url), method) + return { + ok: status >= 200 && status < 300, + status, + statusText: 'OK', + json: async () => body, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), + } + }) as unknown as typeof fetch +} + +function client(token: string | undefined = 'tok') { + return new McpTestClient(mountConnectors([github], { credentials: () => ({ token }) })) +} + +function json(result: McpToolResult): any { + const first = result.content[0] + return first && first.type === 'text' ? JSON.parse(first.text) : undefined +} + +beforeEach(() => { + calls = [] +}) +afterEach(() => { + globalThis.fetch = realFetch +}) + +test('tools are namespaced and writes are not marked read-only', async () => { + const tools = await client().listTools() + const names = tools.map((t) => t.name).sort() + assert.ok(names.includes('github_list-issues')) + assert.ok(names.includes('github_create-issue')) + assert.equal(tools.length, 9) + const read = tools.find((t) => t.name === 'github_get-issue') + const write = tools.find((t) => t.name === 'github_create-issue') + assert.equal(read?.annotations?.readOnlyHint, true) + assert.notEqual(write?.annotations?.readOnlyHint, true) +}) + +test('list-issues filters out pull requests and slims the payload', async () => { + mockFetch(() => ({ + body: [ + { number: 1, title: 'a bug', state: 'open', user: { login: 'alice' }, labels: [{ name: 'bug' }], comments: 2, html_url: 'u1' }, + { number: 2, title: 'a pr', state: 'open', user: { login: 'bob' }, pull_request: { url: 'x' }, html_url: 'u2' }, + ], + })) + const res = json(await client().callTool('github_list-issues', { owner: 'o', repo: 'r' })) + assert.equal(res.length, 1) + assert.deepEqual(res[0], { + number: 1, + title: 'a bug', + state: 'open', + author: 'alice', + labels: ['bug'], + comments: 2, + isPullRequest: false, + url: 'u1', + }) + assert.match(calls[0]!.url, /\/repos\/o\/r\/issues\?state=open&per_page=30/) +}) + +test('get-file base64-decodes content', async () => { + mockFetch(() => ({ + body: { path: 'README.md', size: 5, sha: 'abc', encoding: 'base64', content: Buffer.from('hello').toString('base64') }, + })) + const res = json(await client().callTool('github_get-file', { owner: 'o', repo: 'r', path: 'README.md' })) + assert.equal(res.content, 'hello') + assert.equal(res.path, 'README.md') +}) + +test('comment-on-issue POSTs the body and returns the comment ref', async () => { + mockFetch(() => ({ body: { id: 99, html_url: 'comment-url' } })) + const res = json(await client().callTool('github_comment-on-issue', { owner: 'o', repo: 'r', number: 7, body: 'hi' })) + assert.deepEqual(res, { id: 99, url: 'comment-url' }) + assert.equal(calls[0]!.method, 'POST') + assert.match(calls[0]!.url, /\/repos\/o\/r\/issues\/7\/comments$/) + assert.deepEqual(calls[0]!.body, { body: 'hi' }) +}) + +test('create-issue sends title + body and omits unset labels', async () => { + mockFetch(() => ({ body: { number: 42, html_url: 'issue-url' } })) + const res = json(await client().callTool('github_create-issue', { owner: 'o', repo: 'r', title: 'T', body: 'B' })) + assert.deepEqual(res, { number: 42, url: 'issue-url' }) + assert.deepEqual(calls[0]!.body, { title: 'T', body: 'B' }) +}) + +test('a missing token fails the call with a clear error', async () => { + mockFetch(() => ({ body: {} })) + const tokenless = new McpTestClient(mountConnectors([github], { credentials: () => ({}) })) + await assert.rejects(() => tokenless.callTool('github_get-repo', { owner: 'o', repo: 'r' }), /no GitHub token/) +}) + +test('a non-2xx response surfaces status and detail', async () => { + mockFetch(() => ({ status: 404, body: 'Not Found' })) + await assert.rejects( + () => client().callTool('github_get-issue', { owner: 'o', repo: 'r', number: 1 }), + /404/, + ) +}) diff --git a/packages/connector-github/src/index.ts b/packages/connector-github/src/index.ts new file mode 100644 index 0000000..99a6e6b --- /dev/null +++ b/packages/connector-github/src/index.ts @@ -0,0 +1,209 @@ +import { defineConnector } from '@gemstack/connectors' +import { z } from 'zod' +import { gh } from './client.js' + +export { GitHubError } from './client.js' + +const repo = { owner: z.string().min(1), repo: z.string().min(1) } +const enc = encodeURIComponent + +/** Slim an API issue (or PR-as-issue) down to the fields an agent needs. */ +function slimIssue(i: Record) { + return { + number: i.number, + title: i.title, + state: i.state, + author: i.user?.login, + labels: Array.isArray(i.labels) ? i.labels.map((l: any) => (typeof l === 'string' ? l : l?.name)) : [], + comments: i.comments, + isPullRequest: i.pull_request != null, + url: i.html_url, + } +} + +function slimPull(p: Record) { + return { + number: p.number, + title: p.title, + state: p.state, + draft: p.draft, + author: p.user?.login, + head: p.head?.ref, + base: p.base?.ref, + merged: p.merged, + mergeable: p.mergeable, + url: p.html_url, + } +} + +/** + * GitHub connector: read and act on issues, pull requests, and repo files. + * + * Auth is a token with `repo` scope. Declared as `pat` (set `GITHUB_TOKEN`), + * but an OAuth bearer works identically — the orchestrator supplies whichever + * via the mount `credentials` option. + */ +export default defineConnector({ + id: 'github', + name: 'GitHub', + instructions: 'Read and act on GitHub issues, pull requests, and repository files.', + auth: { type: 'pat', env: 'GITHUB_TOKEN', description: 'GitHub token with `repo` scope (PAT or OAuth bearer)' }, + tools: [ + { + name: 'get-repo', + description: 'Get metadata for a repository.', + schema: z.object({ ...repo }), + annotations: { readOnly: true, openWorld: true }, + handle: async (input: { owner: string; repo: string }, ctx) => { + const r = await gh>(ctx, 'GET', `/repos/${enc(input.owner)}/${enc(input.repo)}`) + return { + fullName: r.full_name, + description: r.description, + private: r.private, + defaultBranch: r.default_branch, + stars: r.stargazers_count, + openIssues: r.open_issues_count, + url: r.html_url, + } + }, + }, + { + name: 'list-issues', + description: 'List issues in a repository (newest first). Excludes pull requests.', + schema: z.object({ + ...repo, + state: z.enum(['open', 'closed', 'all']).optional(), + limit: z.number().int().min(1).max(100).optional(), + }), + annotations: { readOnly: true, openWorld: true }, + handle: async (input: { owner: string; repo: string; state?: string; limit?: number }, ctx) => { + const q = new URLSearchParams({ state: input.state ?? 'open', per_page: String(input.limit ?? 30) }) + const issues = await gh[]>( + ctx, + 'GET', + `/repos/${enc(input.owner)}/${enc(input.repo)}/issues?${q}`, + ) + return issues.filter((i) => i.pull_request == null).map(slimIssue) + }, + }, + { + name: 'get-issue', + description: 'Get a single issue by number.', + schema: z.object({ ...repo, number: z.number().int().positive() }), + annotations: { readOnly: true, openWorld: true }, + handle: async (input: { owner: string; repo: string; number: number }, ctx) => { + const i = await gh>( + ctx, + 'GET', + `/repos/${enc(input.owner)}/${enc(input.repo)}/issues/${input.number}`, + ) + return { ...slimIssue(i), body: i.body } + }, + }, + { + name: 'list-pull-requests', + description: 'List pull requests in a repository.', + schema: z.object({ + ...repo, + state: z.enum(['open', 'closed', 'all']).optional(), + limit: z.number().int().min(1).max(100).optional(), + }), + annotations: { readOnly: true, openWorld: true }, + handle: async (input: { owner: string; repo: string; state?: string; limit?: number }, ctx) => { + const q = new URLSearchParams({ state: input.state ?? 'open', per_page: String(input.limit ?? 30) }) + const pulls = await gh[]>( + ctx, + 'GET', + `/repos/${enc(input.owner)}/${enc(input.repo)}/pulls?${q}`, + ) + return pulls.map(slimPull) + }, + }, + { + name: 'get-pull-request', + description: 'Get a single pull request by number.', + schema: z.object({ ...repo, number: z.number().int().positive() }), + annotations: { readOnly: true, openWorld: true }, + handle: async (input: { owner: string; repo: string; number: number }, ctx) => { + const p = await gh>( + ctx, + 'GET', + `/repos/${enc(input.owner)}/${enc(input.repo)}/pulls/${input.number}`, + ) + return { ...slimPull(p), body: p.body, additions: p.additions, deletions: p.deletions, changedFiles: p.changed_files } + }, + }, + { + name: 'get-file', + description: 'Read the contents of a file in a repository.', + schema: z.object({ ...repo, path: z.string().min(1), ref: z.string().optional() }), + annotations: { readOnly: true, openWorld: true }, + handle: async (input: { owner: string; repo: string; path: string; ref?: string }, ctx) => { + const q = input.ref ? `?ref=${enc(input.ref)}` : '' + const f = await gh>( + ctx, + 'GET', + `/repos/${enc(input.owner)}/${enc(input.repo)}/contents/${input.path.split('/').map(enc).join('/')}${q}`, + ) + if (Array.isArray(f)) return { error: `path "${input.path}" is a directory, not a file` } + const content = + f.encoding === 'base64' && typeof f.content === 'string' + ? Buffer.from(f.content, 'base64').toString('utf8') + : f.content + return { path: f.path, size: f.size, sha: f.sha, content } + }, + }, + { + name: 'search-issues', + description: 'Search issues and pull requests across GitHub with a query (GitHub search syntax).', + schema: z.object({ query: z.string().min(1), limit: z.number().int().min(1).max(100).optional() }), + annotations: { readOnly: true, openWorld: true }, + handle: async (input: { query: string; limit?: number }, ctx) => { + const q = new URLSearchParams({ q: input.query, per_page: String(input.limit ?? 20) }) + const res = await gh<{ total_count: number; items: Record[] }>(ctx, 'GET', `/search/issues?${q}`) + return { totalCount: res.total_count, items: res.items.map(slimIssue) } + }, + }, + { + name: 'comment-on-issue', + description: 'Add a comment to an issue or pull request.', + schema: z.object({ ...repo, number: z.number().int().positive(), body: z.string().min(1) }), + annotations: { openWorld: true }, + handle: async (input: { owner: string; repo: string; number: number; body: string }, ctx) => { + const c = await gh>( + ctx, + 'POST', + `/repos/${enc(input.owner)}/${enc(input.repo)}/issues/${input.number}/comments`, + { body: input.body }, + ) + return { id: c.id, url: c.html_url } + }, + }, + { + name: 'create-issue', + description: 'Open a new issue in a repository.', + schema: z.object({ + ...repo, + title: z.string().min(1), + body: z.string().optional(), + labels: z.array(z.string()).optional(), + }), + annotations: { openWorld: true }, + handle: async ( + input: { owner: string; repo: string; title: string; body?: string; labels?: string[] }, + ctx, + ) => { + const payload: Record = { title: input.title } + if (input.body != null) payload.body = input.body + if (input.labels != null) payload.labels = input.labels + const i = await gh>( + ctx, + 'POST', + `/repos/${enc(input.owner)}/${enc(input.repo)}/issues`, + payload, + ) + return { number: i.number, url: i.html_url } + }, + }, + ], +}) diff --git a/packages/connector-github/tsconfig.build.json b/packages/connector-github/tsconfig.build.json new file mode 100644 index 0000000..e578064 --- /dev/null +++ b/packages/connector-github/tsconfig.build.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/connector-github/tsconfig.json b/packages/connector-github/tsconfig.json new file mode 100644 index 0000000..404aab4 --- /dev/null +++ b/packages/connector-github/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/connector-github/tsconfig.test.json b/packages/connector-github/tsconfig.test.json new file mode 100644 index 0000000..eebda2f --- /dev/null +++ b/packages/connector-github/tsconfig.test.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist-test", "rootDir": "src" }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e06b04e..27a4f90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,6 +172,28 @@ importers: specifier: ^5.4.0 version: 5.9.3 + packages/connector-github: + dependencies: + '@gemstack/connectors': + specifier: workspace:^ + version: link:../connectors + '@gemstack/mcp': + specifier: workspace:^ + version: link:../mcp + zod: + specifier: ^4.0.0 + version: 4.4.3 + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.43 + reflect-metadata: + specifier: ^0.2.0 + version: 0.2.2 + typescript: + specifier: ^5.4.0 + version: 5.9.3 + packages/connectors: dependencies: '@gemstack/mcp':