Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/connector-github.md
Original file line number Diff line number Diff line change
@@ -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).
48 changes: 48 additions & 0 deletions packages/connector-github/README.md
Original file line number Diff line number Diff line change
@@ -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.
57 changes: 57 additions & 0 deletions packages/connector-github/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
52 changes: 52 additions & 0 deletions packages/connector-github/src/client.ts
Original file line number Diff line number Diff line change
@@ -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<T = unknown>(
ctx: ConnectorContext,
method: string,
path: string,
body?: unknown,
): Promise<T> {
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
}
121 changes: 121 additions & 0 deletions packages/connector-github/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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/,
)
})
Loading
Loading