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
13 changes: 13 additions & 0 deletions .changeset/gemstack-mcp-initial.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@gemstack/mcp": minor
---

Initial release. An agent-agnostic framework for authoring MCP servers — the graduation of the mature `@rudderjs/mcp` into a standalone, dependency-light package (runtime deps: `@modelcontextprotocol/sdk`, `zod`, `reflect-metadata`; zero `@rudderjs/*`).

- `McpServer` / `McpTool` / `McpResource` / `McpPrompt` / `McpResponse` / `Mcp` plus the metadata + MCP-spec annotation decorators.
- **Instance-scoped DI seam**: `@Handle(...)` resolves dependencies through a resolver passed at construction (`new Server({ resolver })`), never off `globalThis`. Built-in `createResolver().register(token, instance)` for the no-container case; a `@Handle` dependency with no resolver (or a resolver yielding `undefined`) fails loudly, naming the member and token — never injects `undefined`.
- **Framework-neutral HTTP**: `createMcpHttpHandler(server)` returns a plain `node:http` `(req, res)` handler (also fits Express/Connect); `createWebRequestHandler(server)` returns a Web Standard `(request) => Promise<Response>` for Hono/Vike/edge runtimes. `startStdio` for CLI/stdio.
- **Generic OAuth 2.1**: `oauth2McpMiddleware` takes a user-supplied `verifyToken` (the binding wires its own auth) and emits RFC 9728 protected-resource metadata; no auth provider baked in.
- `McpTestClient` for in-process testing, and an observer registry for tool/resource/prompt tracing.

Schema conversion uses Zod 4's native `z.toJSONSchema` directly. The Rudder-specific provider, CLI scaffolders, and doctor check stay in `@rudderjs/mcp`, which becomes a thin binding over this core (Phase 2).
146 changes: 146 additions & 0 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# @gemstack/mcp

An agent-agnostic framework for **authoring Model Context Protocol (MCP) servers** in TypeScript: declare tools, resources, and prompts as classes; serve them over a framework-neutral HTTP handler or stdio; protect them with OAuth 2.1. No framework required.

This is the graduation of the mature `@rudderjs/mcp` server framework into a standalone, dependency-light package. Its only runtime dependencies are `@modelcontextprotocol/sdk`, `zod`, and `reflect-metadata`.

## Which MCP package do I want?

There are two MCP packages in GemStack, on opposite axes — don't conflate them:

| Package | Axis | Use it to… |
|---|---|---|
| **`@gemstack/mcp`** (this) | **server authoring** | Build an MCP *server*: hand-author tools/resources/prompts and serve them. Agent-agnostic — depends on no AI runtime. |
| `@gemstack/ai-mcp` | agent ↔ MCP bridge | Consume remote MCP tools as `@gemstack/ai-sdk` Agent tools, or expose a single Agent as an MCP server. Depends on `@gemstack/ai-sdk`. |

## Install

```bash
npm install @gemstack/mcp
```

`reflect-metadata` must be imported once at your entry point (for the decorators):

```ts
import 'reflect-metadata'
```

## Quick start

Define a tool and a server:

```ts
import { McpServer, McpTool, McpResponse, Name, Description } from '@gemstack/mcp'
import { z } from 'zod'

@Description('Echo a message back to the caller')
class EchoTool extends McpTool {
schema() { return z.object({ message: z.string() }) }
async handle(input: { message: string }) {
return McpResponse.text(input.message)
}
}

@Name('demo')
class DemoServer extends McpServer {
protected tools = [EchoTool]
}
```

Serve it over raw `node:http` — no framework involved:

```ts
import { createServer } from 'node:http'
import { createMcpHttpHandler } from '@gemstack/mcp'

const handler = createMcpHttpHandler(new DemoServer())
createServer((req, res) => { void handler(req, res) }).listen(3000)
```

`createMcpHttpHandler` returns a plain `(req, res)` handler, so it also mounts on Express/Connect. For Hono, Vike, or any Fetch-style runtime, use `createWebRequestHandler` from `@gemstack/mcp/runtime` (`(request: Request) => Promise<Response>`). For a CLI/stdio server, use `startStdio` from the same subpath.

### Resources and prompts

```ts
import { McpResource, McpPrompt } from '@gemstack/mcp'

class VersionResource extends McpResource {
uri() { return 'info://version' }
async handle() { return '1.0.0' }
}

class GreetPrompt extends McpPrompt {
arguments() { return z.object({ name: z.string() }) }
async handle(args: { name: string }) {
return [{ role: 'user' as const, content: `Hello ${args.name}` }]
}
}
```

URI templates (`weather://location/{city}`) are matched and their params passed to `handle(params)`.

## Dependency injection — `@Handle`

A tool/resource/prompt method can ask for dependencies beyond its first argument. Mark it with `@Handle(...)` and construct the server with a **resolver**:

```ts
import { McpServer, McpTool, McpResponse, Handle, createResolver } from '@gemstack/mcp'

class Logger { info(msg: string) { console.log(msg) } }

class LogTool extends McpTool {
schema() { return z.object({ message: z.string() }) }
@Handle(Logger)
async handle(input: { message: string }, log: Logger) {
log.info(input.message)
return McpResponse.text('logged')
}
}

class LogServer extends McpServer { protected tools = [LogTool] }

const resolver = createResolver().register(Logger, new Logger())
const server = new LogServer({ resolver })
```

The resolver is **instance-scoped** — passed at construction, never read off a global. Wire it to any container (Awilix, tsyringe, InversifyJS, a framework binding) with a one-function adapter implementing `McpResolver = { resolve(token): unknown }`. If a `@Handle` method requests a dependency and no resolver is provided — or the resolver yields `undefined` — the call fails loudly, naming the member and token; it never injects `undefined`.

## OAuth 2.1

Protect a web endpoint with bearer tokens. The core is auth-agnostic: you supply a `verifyToken` that validates the JWT (signature, expiry, revocation) and returns its claims, or `null`/throws when invalid.

```ts
import { oauth2McpMiddleware } from '@gemstack/mcp'

const mw = oauth2McpMiddleware('/mcp', {
scopes: ['mcp.read'],
verifyToken: async (jwt) => {
// validate however you like; return claims or null
return { sub: 'user-1', scopes: ['mcp.read'] }
},
})
```

On success the verified claims are attached to the request as `req.mcpAuth`. `registerOAuth2Metadata(...)` emits the RFC 9728 protected-resource metadata document.

## Testing

`McpTestClient` exercises a server's tools/resources/prompts in-process, with no transport:

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

const client = new McpTestClient(DemoServer)
const result = await client.callTool('echo', { message: 'hi' })

// With DI:
const client2 = new McpTestClient(LogServer, { resolver })
```

## Observers

Subscribe to structured tool/resource/prompt events (for tracing/telemetry) via `@gemstack/mcp/observers`.

## License

MIT
71 changes: 71 additions & 0 deletions packages/mcp/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
{
"name": "@gemstack/mcp",
"version": "0.0.0",
"description": "Agent-agnostic framework for authoring Model Context Protocol (MCP) servers: tools, resources, prompts, decorators, OAuth 2.1, a framework-neutral HTTP handler, and a test client. The graduation of @rudderjs/mcp.",
"keywords": [
"mcp",
"model-context-protocol",
"server",
"tools",
"resources",
"prompts",
"ai",
"agents",
"oauth",
"gemstack"
],
"license": "MIT",
"homepage": "https://github.com/gemstack-land/gemstack/tree/main/packages/mcp#readme",
"bugs": {
"url": "https://github.com/gemstack-land/gemstack/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/gemstack-land/gemstack",
"directory": "packages/mcp"
},
"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"
},
"./observers": {
"import": "./dist/observers.js",
"types": "./dist/observers.d.ts"
},
"./runtime": {
"import": "./dist/runtime.js",
"types": "./dist/runtime.d.ts"
},
"./testing": {
"import": "./dist/testing.js",
"types": "./dist/testing.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": {
"@modelcontextprotocol/sdk": "^1.29.0",
"reflect-metadata": "^0.2.0",
"zod": "^4.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.4.0"
},
"author": "Suleiman Shahbari"
}
80 changes: 80 additions & 0 deletions packages/mcp/src/Mcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { McpServer, McpServerOptions } from './McpServer.js'
import type { OAuth2McpOptions } from './auth/oauth2.js'
import type { McpResolver } from './resolver.js'

type ServerClass = new (options?: McpServerOptions) => McpServer

export interface McpWebEntry {
server: ServerClass
middleware: unknown[]
/** Set when `.oauth2()` was chained on the builder. */
oauth2?: OAuth2McpOptions
/** Set when `.resolver()` was chained — the DI resolver to construct the server with. */
resolver?: McpResolver
}

export interface McpWebBuilder {
/** Add middleware to this web MCP endpoint. */
middleware(mw: unknown[]): McpWebBuilder
/**
* Protect this endpoint with OAuth 2.1 bearer tokens. Registers an RFC 9728
* Protected Resource Metadata endpoint alongside it. Supply a `verifyToken`
* (see {@link OAuth2McpOptions}) so the endpoint can validate bearer tokens.
*/
oauth2(options?: OAuth2McpOptions): McpWebBuilder
/** Construct this server with a DI resolver (for `@Handle()` dependencies). */
resolver(resolver: McpResolver): McpWebBuilder
}

/**
* Shared singleton store routed through `globalThis` so the registry survives
* the case where `@gemstack/mcp` is loaded twice — typical in a bundled server
* where the host inlines `@gemstack/mcp` but `Mcp.web()` / `Mcp.local()` calls
* run from a separate `node_modules` copy. Without a shared store, servers
* registered from one copy would be invisible to the mounter reading the other
* — every `/mcp/*` request would 404.
*/
interface McpServersStore {
web: Map<string, McpWebEntry>
local: Map<string, ServerClass>
}

const _g = globalThis as Record<string, unknown>
if (!_g['__gemstack_mcp_servers__']) {
_g['__gemstack_mcp_servers__'] = {
web: new Map<string, McpWebEntry>(),
local: new Map<string, ServerClass>(),
} satisfies McpServersStore
}
const _store = _g['__gemstack_mcp_servers__'] as McpServersStore

export class Mcp {
/** Register an MCP server on an HTTP endpoint (Streamable HTTP transport) */
static web(path: string, server: ServerClass, middleware: unknown[] = []): McpWebBuilder {
const entry: McpWebEntry = { server, middleware }
_store.web.set(path, entry)
const builder: McpWebBuilder = {
middleware(mw: unknown[]) {
entry.middleware.push(...mw)
return builder
},
oauth2(options: OAuth2McpOptions = {}) {
entry.oauth2 = options
return builder
},
resolver(resolver: McpResolver) {
entry.resolver = resolver
return builder
},
}
return builder
}

/** Register an MCP server as a local CLI command (stdio transport) */
static local(name: string, server: ServerClass): void {
_store.local.set(name, server)
}

static getWebServers(): Map<string, McpWebEntry> { return _store.web }
static getLocalServers(): Map<string, ServerClass> { return _store.local }
}
37 changes: 37 additions & 0 deletions packages/mcp/src/McpPrompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { toKebabCase } from './utils.js'
import { getDescription } from './decorators.js'
import type { ZodLikeObject } from './types.js'

export interface McpPromptMessage {
role: 'user' | 'assistant'
content: string
}

export abstract class McpPrompt {
/** Prompt name */
name(): string {
return toKebabCase(this.constructor.name.replace(/Prompt$/, ''))
}

/** Description */
description(): string {
return getDescription(this.constructor) ?? ''
}

/** Arguments schema — a Zod object (v3 or v4). */
arguments?(): ZodLikeObject

/**
* Generate prompt messages. Extra parameters beyond `args` are resolved
* from the DI container when the method is decorated with `@Handle()`.
*/
abstract handle(args: Record<string, unknown>, ...deps: unknown[]): Promise<McpPromptMessage[]>

/**
* Optional hook controlling whether this prompt is exposed to clients.
*
* Returning `false` hides the prompt from `prompts/list` AND causes
* `prompts/get` to throw "Unknown prompt" — preventing bypass.
*/
shouldRegister?(): boolean | Promise<boolean>
}
37 changes: 37 additions & 0 deletions packages/mcp/src/McpResource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { getDescription } from './decorators.js'

export abstract class McpResource {
/** Resource URI pattern — can contain `{param}` placeholders for templates */
abstract uri(): string

/** MIME type */
mimeType(): string {
return 'text/plain'
}

/** Resource description */
description(): string {
return getDescription(this.constructor) ?? ''
}

/** Whether this resource uses URI templates (has `{param}` placeholders) */
isTemplate(): boolean {
return this.uri().includes('{')
}

/**
* Handle resource read. Receives extracted params if this is a template
* resource. Extra parameters beyond `params` are resolved from the DI
* container when the method is decorated with `@Handle()`.
*/
abstract handle(params?: Record<string, string>, ...deps: unknown[]): Promise<string>

/**
* Optional hook controlling whether this resource is exposed to clients.
*
* Returning `false` hides the resource from `resources/list` and
* `resources/templates/list`, AND causes `resources/read` to throw
* "Unknown resource" — preventing bypass via direct URI.
*/
shouldRegister?(): boolean | Promise<boolean>
}
Loading
Loading