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/connectors-kit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@gemstack/connectors": minor
---

New package: the connector contract for GemStack AI orchestration. `defineConnector` declares a tool connector to an external service (its auth requirement + tools); `mountConnectors` composes any number into a single `@gemstack/mcp` server, namespacing tools by connector id and resolving credentials at call time. Built on `@gemstack/mcp`, framework-agnostic. First step of the connectors epic (GitHub, Google Drive to follow).
14 changes: 14 additions & 0 deletions examples/connectors-quickstart/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# connectors-quickstart

A runnable reference connector built with [`@gemstack/connectors`](../../packages/connectors). Copy `src/library-connector.ts` to start a real connector: swap the in-memory data for calls to your external service, and change `auth` from `none` to `pat` / `oauth`.

```bash
pnpm --filter @gemstack/example-connectors-quickstart start # run the demo
pnpm --filter @gemstack/example-connectors-quickstart test # run the smoke test
```

What it shows:

- `defineConnector(...)` — one read-only connector (`library`) with three tools.
- `mountConnectors([library], { credentials })` — composing it into a standard `@gemstack/mcp` server, with the credential seam wired.
- Driving it through `McpTestClient` — `listTools()` shows tools namespaced as `library_*`, `callTool(...)` runs them.
24 changes: 24 additions & 0 deletions examples/connectors-quickstart/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@gemstack/example-connectors-quickstart",
"version": "0.0.0",
"private": true,
"description": "Runnable reference connector built with @gemstack/connectors: a read-only in-memory source, mounted into an MCP server and exercised with McpTestClient. Copy from this to write a real connector.",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"test": "tsc -p tsconfig.test.json && cd dist-test && node --test",
"clean": "rm -rf dist-test",
"start": "tsx src/demo.ts"
},
"dependencies": {
"@gemstack/connectors": "workspace:^",
"@gemstack/mcp": "workspace:^",
"reflect-metadata": "^0.2.0",
"zod": "^4.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"tsx": "^4.19.0",
"typescript": "^5.4.0"
}
}
31 changes: 31 additions & 0 deletions examples/connectors-quickstart/src/connector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import 'reflect-metadata'
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { mountConnectors } from '@gemstack/connectors'
import { McpTestClient } from '@gemstack/mcp/testing'
import type { McpToolResult } from '@gemstack/mcp'
import library from './library-connector.js'

const client = new McpTestClient(mountConnectors([library]))

function txt(result: McpToolResult): string {
const first = result.content[0]
return first && first.type === 'text' ? first.text : ''
}

test('reference connector exposes its tools, namespaced', async () => {
const names = (await client.listTools()).map((t) => t.name).sort()
assert.deepEqual(names, ['library_get-book', 'library_list-books', 'library_search-books'])
})

test('search-books filters by title substring', async () => {
const res = await client.callTool('library_search-books', { query: 'design' })
const body = txt(res)
assert.match(body, /Domain-Driven Design/)
assert.doesNotMatch(body, /Refactoring/)
})

test('get-book returns one book by id', async () => {
const res = await client.callTool('library_get-book', { id: 'b1' })
assert.match(txt(res), /Pragmatic/)
})
32 changes: 32 additions & 0 deletions examples/connectors-quickstart/src/demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Runnable: `pnpm --filter @gemstack/example-connectors-quickstart start`
// Mounts the reference connector and drives it through McpTestClient (no server
// or transport needed to see it working).
import 'reflect-metadata'
import { mountConnectors } from '@gemstack/connectors'
import { McpTestClient } from '@gemstack/mcp/testing'
import library from './library-connector.js'

const Server = mountConnectors([library], {
name: 'connectors-quickstart',
// A real orchestrator would resolve a real token per connector here.
credentials: (id) => ({ token: process.env[`${id.toUpperCase()}_TOKEN`] }),
})

const client = new McpTestClient(Server)

const tools = await client.listTools()
console.log(
'tools:',
tools.map((t) => t.name),
)

const search = await client.callTool('library_search-books', { query: 'design' })
console.log('search "design":', text(search))

const book = await client.callTool('library_get-book', { id: 'b1' })
console.log('get b1:', text(book))

function text(result: { content: Array<{ type: string; text?: string }> }): string {
const first = result.content[0]
return first && first.type === 'text' ? (first.text ?? '') : JSON.stringify(result.content)
}
56 changes: 56 additions & 0 deletions examples/connectors-quickstart/src/library-connector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// A reference connector. Copy this file to start a real one: swap the in-memory
// data for calls to your external service, and change `auth` to `pat` / `oauth`.
import { defineConnector } from '@gemstack/connectors'
import { z } from 'zod'

interface Book {
id: string
title: string
author: string
}

// Stand-in for an external service. A real connector calls an API here, using
// `ctx.auth.token` (see the `handle` signatures below).
const BOOKS: Book[] = [
{ id: 'b1', title: 'The Pragmatic Programmer', author: 'Hunt & Thomas' },
{ id: 'b2', title: 'Refactoring', author: 'Fowler' },
{ id: 'b3', title: 'Domain-Driven Design', author: 'Evans' },
]

export default defineConnector({
id: 'library',
name: 'Reference Library',
instructions: 'A read-only demo connector over a small in-memory book list.',
// This demo needs no credential. A real connector declares `pat` or `oauth`;
// the orchestrator then resolves a token and hands it via `ctx.auth`.
auth: { type: 'none' },
tools: [
{
name: 'list-books',
description: 'List every book in the library.',
schema: z.object({}),
annotations: { readOnly: true, openWorld: true },
handle: () => BOOKS,
},
{
name: 'search-books',
description: 'Search books by a case-insensitive substring of the title.',
schema: z.object({ query: z.string().min(1) }),
annotations: { readOnly: true, openWorld: true },
handle: (input: { query: string }) => {
const q = input.query.toLowerCase()
return BOOKS.filter((b) => b.title.toLowerCase().includes(q))
},
},
{
name: 'get-book',
description: 'Fetch one book by id.',
schema: z.object({ id: z.string() }),
annotations: { readOnly: true, openWorld: true },
handle: (input: { id: string }) => {
const book = BOOKS.find((b) => b.id === input.id)
return book ?? { error: `no book with id ${input.id}` }
},
},
],
})
5 changes: 5 additions & 0 deletions examples/connectors-quickstart/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "noEmit": true, "rootDir": "src" },
"include": ["src"]
}
5 changes: 5 additions & 0 deletions examples/connectors-quickstart/tsconfig.test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist-test", "rootDir": "src" },
"include": ["src"]
}
98 changes: 98 additions & 0 deletions packages/connectors/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# @gemstack/connectors

The connector contract for GemStack AI orchestration. Define a tool connector to an external service (GitHub, Google Drive, ...) once with `defineConnector`, then compose any number of connectors into a single MCP server with `mountConnectors`.

Built on [`@gemstack/mcp`](../mcp). Framework-agnostic: the server it produces plugs into the same surface as any other `@gemstack/mcp` server (`Mcp.web` / `Mcp.local`, `McpTestClient`, the neutral HTTP handler).

> A connector only **declares** what it needs (its auth requirement) and **what it does** (its tools). It never reaches for env vars, OAuth, or a transport itself. The orchestrator that mounts it supplies credentials and chooses how to serve it. That split is what lets first-party and third-party connectors compose interchangeably.

## Install

```bash
npm i @gemstack/connectors @gemstack/mcp zod
```

## Define a connector

```ts
import { defineConnector } from '@gemstack/connectors'
import { z } from 'zod'

export default defineConnector({
id: 'notes', // lowercase, used to namespace this connector's tools
name: 'Notes',
auth: { type: 'none' }, // 'none' | 'pat' | 'oauth'
tools: [
{
name: 'list',
description: 'List all notes',
schema: z.object({}),
annotations: { readOnly: true },
handle: () => ['buy milk', 'ship connectors'],
},
{
name: 'get',
description: 'Get one note by index',
schema: z.object({ index: z.number() }),
annotations: { readOnly: true },
handle: (input, ctx) => {
// ctx.auth carries the credential the orchestrator resolved for 'notes'
return getNote(input.index)
},
},
],
})
```

A tool's `handle` may return a `string` (wrapped as text), any JSON-serializable value (wrapped as pretty JSON), or a full `McpToolResult` (use `McpResponse` from `@gemstack/mcp` for errors / images).

## Mount connectors into a server

```ts
import { mountConnectors } from '@gemstack/connectors'
import { Mcp } from '@gemstack/mcp'
import notes from './notes.js'
import github from '@gemstack/connector-github'

const Server = mountConnectors([notes, github], {
name: 'my-orchestrator',
// Resolve each connector's credential at call time. This is the seam that
// satisfies a connector's declared `auth`.
credentials: (id) => ({ token: process.env[`${id.toUpperCase()}_TOKEN`] }),
})

// The result is a standard @gemstack/mcp server class.
Mcp.web('/mcp/connectors', Server)
```

Tools are namespaced by connector id, so `notes.list` is exposed as `notes_list` and never collides with another connector's `list`. Pass `namespace: 'none'` to keep names verbatim (you then own collision-avoidance).

## Auth requirements

A connector declares one of:

| `auth.type` | Means | Credential the orchestrator provides |
|---|---|---|
| `none` | Public / local data | `{}` |
| `pat` | Personal access token / API key (`env` names a default var) | `{ token }` |
| `oauth` | OAuth 2.1 bearer (`scopes`, `authorizationServers`) | `{ token }` |

For `oauth`, protect the mounted endpoint with `@gemstack/mcp`'s `oauth2McpMiddleware` + `registerOAuth2Metadata` and feed the verified token through `credentials`.

## Testing

```ts
import { McpTestClient } from '@gemstack/mcp/testing'
import { mountConnectors } from '@gemstack/connectors'

const client = new McpTestClient(mountConnectors([notes]))
await client.listTools() // [{ name: 'notes_list', ... }, { name: 'notes_get', ... }]
await client.callTool('notes_get', { index: 0 })
```

## API

- `defineConnector(def): Connector` — validate + fill defaults.
- `mountConnectors(connectors, options?): ConnectorServerClass` — compose into one `@gemstack/mcp` server class.

See `examples/connectors-quickstart` in the repo for a runnable reference connector to copy from.
58 changes: 58 additions & 0 deletions packages/connectors/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{
"name": "@gemstack/connectors",
"version": "0.0.0",
"description": "The connector contract for GemStack AI orchestration: define a tool connector to an external service (GitHub, Google Drive, ...) once with `defineConnector`, compose any number into a single MCP server with `mountConnectors`. Built on @gemstack/mcp; framework-agnostic.",
"keywords": [
"mcp",
"model-context-protocol",
"connector",
"connectors",
"ai",
"agents",
"orchestration",
"tools",
"gemstack"
],
"license": "MIT",
"homepage": "https://github.com/gemstack-land/gemstack/tree/main/packages/connectors#readme",
"bugs": {
"url": "https://github.com/gemstack-land/gemstack/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/gemstack-land/gemstack",
"directory": "packages/connectors"
},
"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/mcp": "workspace:^",
"reflect-metadata": "^0.2.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.4.0",
"zod": "^4.0.0"
},
"author": "Suleiman Shahbari"
}
58 changes: 58 additions & 0 deletions packages/connectors/src/defineConnector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { Connector, ConnectorDefinition } from './types.js'

/** Connector ids and tool names share this charset so namespaced names stay MCP-safe. */
const ID_RE = /^[a-z][a-z0-9-]*$/

/**
* Define a tool connector to an external service. Validates the shape and fills
* defaults, returning a {@link Connector} ready to pass to `mountConnectors`.
*
* ```ts
* export default defineConnector({
* id: 'github',
* auth: { type: 'oauth', scopes: ['repo'] },
* tools: [{ name: 'list-issues', schema: ..., handle: async (input, ctx) => ... }],
* })
* ```
*/
export function defineConnector(def: ConnectorDefinition): Connector {
if (!def || typeof def !== 'object') {
throw new TypeError('defineConnector: expected a connector definition object')
}
if (!ID_RE.test(def.id ?? '')) {
throw new Error(
`defineConnector: invalid id ${JSON.stringify(def.id)} — use lowercase letters, digits, and "-" (must start with a letter)`,
)
}
if (!Array.isArray(def.tools) || def.tools.length === 0) {
throw new Error(`defineConnector("${def.id}"): at least one tool is required`)
}

const seen = new Set<string>()
for (const tool of def.tools) {
if (!ID_RE.test(tool?.name ?? '')) {
throw new Error(
`defineConnector("${def.id}"): invalid tool name ${JSON.stringify(tool?.name)} — use lowercase letters, digits, and "-"`,
)
}
if (seen.has(tool.name)) {
throw new Error(`defineConnector("${def.id}"): duplicate tool name "${tool.name}"`)
}
seen.add(tool.name)
if (typeof tool.handle !== 'function') {
throw new Error(`defineConnector("${def.id}"): tool "${tool.name}" is missing a handle() function`)
}
if (typeof tool.schema !== 'object' || tool.schema == null) {
throw new Error(`defineConnector("${def.id}"): tool "${tool.name}" is missing a Zod schema`)
}
}

return {
id: def.id,
name: def.name ?? def.id,
version: def.version ?? '1.0.0',
...(def.instructions != null ? { instructions: def.instructions } : {}),
auth: def.auth ?? { type: 'none' },
tools: def.tools,
}
}
Loading
Loading