diff --git a/.changeset/connectors-kit.md b/.changeset/connectors-kit.md new file mode 100644 index 0000000..37981f9 --- /dev/null +++ b/.changeset/connectors-kit.md @@ -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). diff --git a/examples/connectors-quickstart/README.md b/examples/connectors-quickstart/README.md new file mode 100644 index 0000000..23b3261 --- /dev/null +++ b/examples/connectors-quickstart/README.md @@ -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. diff --git a/examples/connectors-quickstart/package.json b/examples/connectors-quickstart/package.json new file mode 100644 index 0000000..884312e --- /dev/null +++ b/examples/connectors-quickstart/package.json @@ -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" + } +} diff --git a/examples/connectors-quickstart/src/connector.test.ts b/examples/connectors-quickstart/src/connector.test.ts new file mode 100644 index 0000000..3d07d60 --- /dev/null +++ b/examples/connectors-quickstart/src/connector.test.ts @@ -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/) +}) diff --git a/examples/connectors-quickstart/src/demo.ts b/examples/connectors-quickstart/src/demo.ts new file mode 100644 index 0000000..521e4c5 --- /dev/null +++ b/examples/connectors-quickstart/src/demo.ts @@ -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) +} diff --git a/examples/connectors-quickstart/src/library-connector.ts b/examples/connectors-quickstart/src/library-connector.ts new file mode 100644 index 0000000..5728704 --- /dev/null +++ b/examples/connectors-quickstart/src/library-connector.ts @@ -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}` } + }, + }, + ], +}) diff --git a/examples/connectors-quickstart/tsconfig.json b/examples/connectors-quickstart/tsconfig.json new file mode 100644 index 0000000..404aab4 --- /dev/null +++ b/examples/connectors-quickstart/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "rootDir": "src" }, + "include": ["src"] +} diff --git a/examples/connectors-quickstart/tsconfig.test.json b/examples/connectors-quickstart/tsconfig.test.json new file mode 100644 index 0000000..eebda2f --- /dev/null +++ b/examples/connectors-quickstart/tsconfig.test.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist-test", "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/connectors/README.md b/packages/connectors/README.md new file mode 100644 index 0000000..9aad55b --- /dev/null +++ b/packages/connectors/README.md @@ -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. diff --git a/packages/connectors/package.json b/packages/connectors/package.json new file mode 100644 index 0000000..2834b81 --- /dev/null +++ b/packages/connectors/package.json @@ -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" +} diff --git a/packages/connectors/src/defineConnector.ts b/packages/connectors/src/defineConnector.ts new file mode 100644 index 0000000..c17b5d5 --- /dev/null +++ b/packages/connectors/src/defineConnector.ts @@ -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() + 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, + } +} diff --git a/packages/connectors/src/index.test.ts b/packages/connectors/src/index.test.ts new file mode 100644 index 0000000..c9132c0 --- /dev/null +++ b/packages/connectors/src/index.test.ts @@ -0,0 +1,118 @@ +import 'reflect-metadata' +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { z } from 'zod' +import { McpTestClient } from '@gemstack/mcp/testing' +import { defineConnector, mountConnectors } from './index.js' +import type { ConnectorContext } from './index.js' +import type { McpToolResult } from '@gemstack/mcp' + +function txt(result: McpToolResult): string { + const first = result.content[0] + return first && first.type === 'text' ? first.text : '' +} + +// A trivial read-only in-memory connector used across the suite. +function notesConnector() { + const notes = ['buy milk', 'ship connectors'] + return defineConnector({ + id: 'notes', + name: 'Notes', + auth: { type: 'none' }, + tools: [ + { + name: 'list', + description: 'List all notes', + schema: z.object({}), + annotations: { readOnly: true }, + handle: () => notes, + }, + { + name: 'get', + description: 'Get one note by index', + schema: z.object({ index: z.number() }), + annotations: { readOnly: true }, + handle: (input: { index: number }) => notes[input.index] ?? 'not found', + }, + ], + }) +} + +test('defineConnector fills defaults', () => { + const c = defineConnector({ id: 'x', tools: [{ name: 'ping', schema: z.object({}), handle: () => 'pong' }] }) + assert.equal(c.name, 'x') + assert.equal(c.version, '1.0.0') + assert.deepEqual(c.auth, { type: 'none' }) +}) + +test('defineConnector rejects bad ids, empty tools, and dup tool names', () => { + assert.throws(() => defineConnector({ id: 'Bad Id', tools: [{ name: 'a', schema: z.object({}), handle: () => '' }] })) + assert.throws(() => defineConnector({ id: 'ok', tools: [] })) + assert.throws(() => + defineConnector({ + id: 'ok', + tools: [ + { name: 'dup', schema: z.object({}), handle: () => '' }, + { name: 'dup', schema: z.object({}), handle: () => '' }, + ], + }), + ) +}) + +test('mountConnectors namespaces tool names by connector id', async () => { + const client = new McpTestClient(mountConnectors([notesConnector()])) + const names = (await client.listTools()).map((t) => t.name).sort() + assert.deepEqual(names, ['notes_get', 'notes_list']) +}) + +test('namespace: none keeps tool names verbatim', async () => { + const client = new McpTestClient(mountConnectors([notesConnector()], { namespace: 'none' })) + const names = (await client.listTools()).map((t) => t.name).sort() + assert.deepEqual(names, ['get', 'list']) +}) + +test('handler return values normalize to MCP results', async () => { + const client = new McpTestClient(mountConnectors([notesConnector()])) + const list = await client.callTool('notes_list') + assert.match(txt(list), /buy milk/) + const one = await client.callTool('notes_get', { index: 1 }) + assert.equal(txt(one), 'ship connectors') +}) + +test('credentials provider threads auth into the tool context', async () => { + let captured: ConnectorContext | undefined + const probe = defineConnector({ + id: 'probe', + auth: { type: 'pat' }, + tools: [ + { + name: 'whoami', + schema: z.object({}), + handle: (_input, ctx) => { + captured = ctx + return ctx.auth.token ?? 'anonymous' + }, + }, + ], + }) + const client = new McpTestClient( + mountConnectors([probe], { credentials: (id) => ({ token: `tok-${id}` }) }), + ) + const res = await client.callTool('probe_whoami') + assert.equal(txt(res), 'tok-probe') + assert.equal(captured?.connectorId, 'probe') + assert.equal(captured?.auth.token, 'tok-probe') +}) + +test('annotations are advertised to clients', async () => { + const client = new McpTestClient(mountConnectors([notesConnector()])) + const list = (await client.listTools()).find((t) => t.name === 'notes_list') + assert.equal(list?.annotations?.readOnlyHint, true) +}) + +test('mountConnectors rejects cross-connector name collisions without namespacing', () => { + const a = defineConnector({ id: 'a', tools: [{ name: 'x', schema: z.object({}), handle: () => '' }] }) + const b = defineConnector({ id: 'b', tools: [{ name: 'x', schema: z.object({}), handle: () => '' }] }) + // 'a' and 'b' both expose 'x'; with namespace: 'none' the names collide. + assert.throws(() => mountConnectors([a, b], { namespace: 'none' })) +}) diff --git a/packages/connectors/src/index.ts b/packages/connectors/src/index.ts new file mode 100644 index 0000000..65210f4 --- /dev/null +++ b/packages/connectors/src/index.ts @@ -0,0 +1,13 @@ +export { defineConnector } from './defineConnector.js' +export { mountConnectors } from './mountConnectors.js' +export type { ConnectorServerClass, MountOptions } from './mountConnectors.js' +export type { + Connector, + ConnectorDefinition, + ConnectorTool, + ConnectorToolAnnotations, + ConnectorToolReturn, + ConnectorAuth, + ConnectorContext, + Credential, +} from './types.js' diff --git a/packages/connectors/src/mountConnectors.ts b/packages/connectors/src/mountConnectors.ts new file mode 100644 index 0000000..10680b1 --- /dev/null +++ b/packages/connectors/src/mountConnectors.ts @@ -0,0 +1,128 @@ +import { + McpServer, + McpTool, + McpResponse, + IsReadOnly, + IsDestructive, + IsIdempotent, + IsOpenWorld, +} from '@gemstack/mcp' +import type { McpServerOptions, McpToolResult, ZodLikeObject } from '@gemstack/mcp' +import type { Connector, ConnectorContext, ConnectorTool, ConnectorToolReturn, Credential } from './types.js' + +/** A constructable MCP server, ready for `Mcp.web()` / `Mcp.local()` or `McpTestClient`. */ +export type ConnectorServerClass = new (options?: McpServerOptions) => McpServer + +export interface MountOptions { + /** Server name advertised to clients. Default `"connectors"`. */ + name?: string + /** Server version. Default `"1.0.0"`. */ + version?: string + /** Server-level instructions surfaced to the agent. */ + instructions?: string + /** + * Resolve the credential for a connector at tool-call time. Called with the + * connector id before each tool runs; return `undefined` for connectors that + * need none. This is the seam the orchestrator implements to satisfy each + * connector's declared {@link Connector.auth}. + */ + credentials?: (connectorId: string) => Credential | undefined | Promise + /** + * How tool names are disambiguated across connectors. + * - `'prefix'` (default): `"_"`. + * - `'none'`: tool names kept verbatim (you must ensure they don't collide). + */ + namespace?: 'prefix' | 'none' +} + +/** + * Compose any number of connectors into a single MCP server class. Each + * connector's tools become MCP tools on one server, namespaced by connector id + * so names never collide. The returned class plugs straight into the + * @gemstack/mcp surface: register it with `Mcp.web(path, ServerClass)` / + * `Mcp.local(name, ServerClass)`, or drive it in tests with `McpTestClient`. + * + * ```ts + * const Server = mountConnectors([github, drive], { + * credentials: (id) => ({ token: process.env[`${id.toUpperCase()}_TOKEN`] }), + * }) + * Mcp.web('/mcp/connectors', Server) + * ``` + */ +export function mountConnectors(connectors: Connector[], options: MountOptions = {}): ConnectorServerClass { + const namespace = options.namespace ?? 'prefix' + const toolClasses: (new () => McpTool)[] = [] + const seen = new Set() + + for (const connector of connectors) { + for (const tool of connector.tools) { + const toolName = namespace === 'prefix' ? `${connector.id}_${tool.name}` : tool.name + if (seen.has(toolName)) { + throw new Error( + `mountConnectors: duplicate tool name "${toolName}" — two connectors expose the same tool (use namespace: 'prefix')`, + ) + } + seen.add(toolName) + toolClasses.push(makeToolClass(connector, tool, toolName, options)) + } + } + + const name = options.name ?? 'connectors' + const version = options.version ?? '1.0.0' + const instructions = options.instructions + + return class ConnectorsServer extends McpServer { + protected override tools = toolClasses + override metadata() { + return { name, version, ...(instructions != null ? { instructions } : {}) } + } + } +} + +function makeToolClass( + connector: Connector, + tool: ConnectorTool, + toolName: string, + options: MountOptions, +): new () => McpTool { + class ConnectorToolImpl extends McpTool { + override name(): string { + return toolName + } + override description(): string { + return tool.description ?? '' + } + override schema(): ZodLikeObject { + return tool.schema + } + override async handle(input: Record): Promise { + const auth = (await options.credentials?.(connector.id)) ?? {} + const ctx: ConnectorContext = { connectorId: connector.id, auth } + return normalizeResult(await tool.handle(input, ctx)) + } + } + + if (tool.outputSchema) { + const outputSchema = tool.outputSchema + ;(ConnectorToolImpl.prototype as { outputSchema?: () => ZodLikeObject }).outputSchema = () => outputSchema + } + + const a = tool.annotations + if (a?.readOnly) IsReadOnly(true)(ConnectorToolImpl) + if (a?.destructive) IsDestructive(true)(ConnectorToolImpl) + if (a?.idempotent) IsIdempotent(true)(ConnectorToolImpl) + if (a?.openWorld) IsOpenWorld(true)(ConnectorToolImpl) + + return ConnectorToolImpl +} + +/** Wrap a connector handler's return into a {@link McpToolResult}. */ +function normalizeResult(result: ConnectorToolReturn): McpToolResult { + if (isToolResult(result)) return result + if (typeof result === 'string') return McpResponse.text(result) + return McpResponse.json(result) +} + +function isToolResult(value: unknown): value is McpToolResult { + return !!value && typeof value === 'object' && Array.isArray((value as { content?: unknown }).content) +} diff --git a/packages/connectors/src/types.ts b/packages/connectors/src/types.ts new file mode 100644 index 0000000..b441ea2 --- /dev/null +++ b/packages/connectors/src/types.ts @@ -0,0 +1,108 @@ +import type { McpToolResult, ZodLikeObject } from '@gemstack/mcp' + +/** + * What credential a connector needs to reach its external service. The + * orchestrator reads this to know how to satisfy the connector, then hands a + * resolved {@link Credential} to each tool call. The connector only *declares* + * its requirement here; it never reaches for env vars or OAuth itself. + */ +export type ConnectorAuth = + /** No credential needed (public data, local source). */ + | { type: 'none' } + /** A personal access token / API key. `env` names a default env var to read. */ + | { type: 'pat'; description?: string; env?: string } + /** OAuth 2.1 bearer token. Mirrors the @gemstack/mcp OAuth protect options. */ + | { type: 'oauth'; scopes?: string[]; authorizationServers?: string[]; description?: string } + +/** + * A credential resolved for one connector and handed to its tools at call time. + * `token` is the common case (PAT or bearer); extra fields carry anything else + * a connector's credential provider wants to thread through. + */ +export interface Credential { + // `| undefined` is explicit so a provider can return `{ token: process.env.X }` + // directly under exactOptionalPropertyTypes (env reads are string | undefined). + token?: string | undefined + [key: string]: unknown +} + +/** Context passed to every connector tool handler. */ +export interface ConnectorContext { + /** The id of the connector this tool belongs to. */ + connectorId: string + /** The credential resolved for this connector (`{}` when none was provided). */ + auth: Credential +} + +/** + * What a tool handler may return: + * - a full {@link McpToolResult} (use `McpResponse` from @gemstack/mcp), or + * - a `string` (wrapped as a text result), or + * - any JSON-serializable value (wrapped as pretty-printed JSON text). + */ +export type ConnectorToolReturn = McpToolResult | string | unknown + +/** + * Behavioural hints advertised to MCP clients. They mirror the MCP tool + * annotations and let agents reason about a tool before calling it — e.g. only + * auto-approve `readOnly` tools. Default (all unset) means a read-write, + * non-destructive, non-idempotent tool. + */ +export interface ConnectorToolAnnotations { + /** The tool does not modify any state. */ + readOnly?: boolean + /** The tool may perform destructive updates (deletes, overwrites). */ + destructive?: boolean + /** Calling repeatedly with the same input has no additional effect. */ + idempotent?: boolean + /** The tool interacts with the open world (external network / services). */ + openWorld?: boolean +} + +/** A single tool a connector exposes. */ +export interface ConnectorTool> { + /** Tool name, unique within the connector. Kept verbatim (namespaced at mount). */ + name: string + /** One-line description shown to the agent. */ + description?: string + /** Input schema — a Zod object (v3 or v4). */ + schema: ZodLikeObject + /** Optional output schema advertised to clients. */ + outputSchema?: ZodLikeObject + /** Optional behavioural hints. */ + annotations?: ConnectorToolAnnotations + /** Run the tool. Receives validated input and the connector {@link ConnectorContext}. */ + handle: (input: Input, ctx: ConnectorContext) => ConnectorToolReturn | Promise +} + +/** The object passed to {@link defineConnector}. */ +export interface ConnectorDefinition { + /** Stable, unique id (lowercase letters, digits, `-`). Used to namespace tools. */ + id: string + /** Human-readable name. Defaults to `id`. */ + name?: string + /** Connector version. Defaults to `1.0.0`. */ + version?: string + /** Optional instructions surfaced to the agent at the server level. */ + instructions?: string + /** Credential requirement. Defaults to `{ type: 'none' }`. */ + auth?: ConnectorAuth + /** + * The tools this connector exposes (at least one). Typed loosely so each tool + * may annotate its own `handle` input (e.g. `(input: { id: string }) => ...`); + * the Zod `schema` is the runtime source of truth. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tools: ConnectorTool[] +} + +/** A validated connector, as returned by {@link defineConnector}. */ +export interface Connector { + id: string + name: string + version: string + instructions?: string + auth: ConnectorAuth + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tools: ConnectorTool[] +} diff --git a/packages/connectors/tsconfig.build.json b/packages/connectors/tsconfig.build.json new file mode 100644 index 0000000..e578064 --- /dev/null +++ b/packages/connectors/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/connectors/tsconfig.json b/packages/connectors/tsconfig.json new file mode 100644 index 0000000..404aab4 --- /dev/null +++ b/packages/connectors/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/connectors/tsconfig.test.json b/packages/connectors/tsconfig.test.json new file mode 100644 index 0000000..eebda2f --- /dev/null +++ b/packages/connectors/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 a64c6e0..e06b04e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,31 @@ importers: specifier: ^3.5.29 version: 3.5.39(typescript@5.9.3) + examples/connectors-quickstart: + dependencies: + '@gemstack/connectors': + specifier: workspace:^ + version: link:../../packages/connectors + '@gemstack/mcp': + specifier: workspace:^ + version: link:../../packages/mcp + reflect-metadata: + specifier: ^0.2.0 + version: 0.2.2 + zod: + specifier: ^4.0.0 + version: 4.4.3 + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.43 + tsx: + specifier: ^4.19.0 + version: 4.22.4 + typescript: + specifier: ^5.4.0 + version: 5.9.3 + examples/mcp-quickstart: dependencies: '@gemstack/mcp': @@ -147,6 +172,25 @@ importers: specifier: ^5.4.0 version: 5.9.3 + packages/connectors: + dependencies: + '@gemstack/mcp': + specifier: workspace:^ + version: link:../mcp + reflect-metadata: + specifier: ^0.2.0 + version: 0.2.2 + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.43 + typescript: + specifier: ^5.4.0 + version: 5.9.3 + zod: + specifier: ^4.0.0 + version: 4.4.3 + packages/mcp: dependencies: '@modelcontextprotocol/sdk':