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
51 changes: 51 additions & 0 deletions examples/mcp-quickstart/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# @gemstack/mcp quickstart

A runnable, framework-neutral MCP server built with `@gemstack/mcp` and **zero `@rudderjs/*` packages**. It proves the "agent-agnostic, standalone" claim: one tool, one resource, one prompt, dependency injection without a container, and OAuth 2.1 protection, served over both raw `node:http` and Hono.

## What's here

| File | Shows |
|---|---|
| `src/server.ts` | Define a tool / resource / prompt; inject a service with `@Handle` + `createResolver` (no DI container); supply a `verifyToken` for OAuth. |
| `src/node-http.ts` | Serve it over raw `node:http`, protected by OAuth 2.1, via a ~10-line `res` adapter. |
| `src/hono.ts` | Serve the same server on Hono via the Fetch-style `createWebRequestHandler`. |
| `src/quickstart.test.ts` | A CI smoke: authenticated round-trip, a `401` for missing token, and the Hono mount. |

## Run it

From this directory (after `pnpm install` at the repo root):

```bash
# raw node:http, OAuth-protected, on :3000
pnpm start:node

# the same server on Hono
pnpm start:hono
```

Then drive it with any MCP client pointed at `http://localhost:3000/mcp`. For the `node:http` server, send `Authorization: Bearer demo-token` (see `DEMO_TOKEN` in `src/server.ts`).

## Verify (CI)

```bash
pnpm test
```

This boots the servers on ephemeral ports and runs a real MCP session against each (the SDK `Client` over `StreamableHTTPClientTransport`), asserting the authenticated call succeeds, an unauthenticated call is rejected with `401`, and the Hono mount serves the same tools.

## Dependency injection

`makeServer()` passes an **instance-scoped** resolver to the server. `createResolver()` needs no container. To back it with a real container, implement the one-method `McpResolver` over it:

```ts
import { createContainer, asValue } from 'awilix'
import type { McpResolver } from '@gemstack/mcp'

const container = createContainer().register({ greeter: asValue(new Greeter()) })
const resolver: McpResolver = { resolve: (token) => container.resolve((token as { name: string }).name) }
new QuickstartServer({ resolver })
```

## OAuth on the Fetch path

`oauth2McpMiddleware` is Connect-shaped, so `src/node-http.ts` uses it directly. To protect the Hono/Fetch mount, read the `Authorization` header in a Hono middleware and call the same `verifyToken` before delegating to the handler.
27 changes: 27 additions & 0 deletions examples/mcp-quickstart/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "@gemstack/example-mcp-quickstart",
"version": "0.0.0",
"private": true,
"description": "Runnable framework-neutral quickstart for @gemstack/mcp: a protected MCP server on node:http and Hono, with zero @rudderjs/* packages.",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"test": "tsc -p tsconfig.test.json && cd dist-test && node --test",
"clean": "rm -rf dist-test",
"start:node": "tsx src/node-http.ts",
"start:hono": "tsx src/hono.ts"
},
"dependencies": {
"@gemstack/mcp": "workspace:^",
"reflect-metadata": "^0.2.0",
"zod": "^4.0.0"
},
"devDependencies": {
"@hono/node-server": "^1.13.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@types/node": "^20.0.0",
"hono": "^4.6.0",
"tsx": "^4.19.0",
"typescript": "^5.4.0"
}
}
29 changes: 29 additions & 0 deletions examples/mcp-quickstart/src/hono.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import 'reflect-metadata'
import { fileURLToPath } from 'node:url'
import { Hono } from 'hono'
import { createWebRequestHandler } from '@gemstack/mcp/runtime'
import { makeServer } from './server.js'

// The same server, mounted on a framework via the Fetch-style handler. This
// proves @gemstack/mcp is transport-agnostic: createWebRequestHandler returns a
// `(Request) => Promise<Response>`, which is what Hono (and Vike, Bun, Deno,
// Cloudflare Workers) speak natively.
//
// This demo mount is unprotected to keep it short. To protect the Fetch path,
// read the Authorization header in a Hono middleware and call the SAME
// verifyToken from ./server.js before delegating to the handler.
export function createHonoApp(): Hono {
const app = new Hono()
const handler = createWebRequestHandler(makeServer())
app.all('/mcp', (c) => handler(c.req.raw))
return app
}

// Runnable on Node via @hono/node-server: `npx tsx src/hono.ts`.
if (process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url)) {
const { serve } = await import('@hono/node-server')
const port = Number(process.env.PORT ?? 3000)
serve({ fetch: createHonoApp().fetch, port }, (info) => {
console.log(`MCP server on http://localhost:${info.port}/mcp (Hono)`)
})
}
50 changes: 50 additions & 0 deletions examples/mcp-quickstart/src/node-http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import 'reflect-metadata'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import { fileURLToPath } from 'node:url'
import {
createMcpHttpHandler, oauth2McpMiddleware,
type OAuth2Request, type OAuth2Response,
} from '@gemstack/mcp'
import { makeServer, verifyToken, REQUIRED_SCOPES } from './server.js'

const MCP_PATH = '/mcp'

// The OAuth middleware is Connect-shaped (req, res, next) with an Express-like
// `res`. node:http's ServerResponse isn't Express-shaped, so adapt it. This tiny
// adapter is the only glue needed to protect a raw node:http server.
function asOAuth2Res(res: ServerResponse): OAuth2Response {
const extra: Record<string, string> = {}
return {
header(key, value) { extra[key] = value },
status(code) {
return {
json(data: unknown) {
res.writeHead(code, { 'content-type': 'application/json', ...extra })
res.end(JSON.stringify(data))
},
}
},
}
}

// A plain (req, res) handler: OAuth first, then the MCP transport. Mounts on
// node:http directly; the same shape works on Express/Connect.
export function createNodeHandler(): (req: IncomingMessage, res: ServerResponse) => void {
const mcp = createMcpHttpHandler(makeServer())
const auth = oauth2McpMiddleware(MCP_PATH, {
scopes: REQUIRED_SCOPES,
scopesSupported: ['mcp.read', 'mcp.write'],
verifyToken,
})
return (req, res) => {
void auth(req as unknown as OAuth2Request, asOAuth2Res(res), () => { void mcp(req, res) })
}
}

// Runnable entry: `npx tsx src/node-http.ts` (or run the compiled file).
if (process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url)) {
const port = Number(process.env.PORT ?? 3000)
createServer(createNodeHandler()).listen(port, () => {
console.log(`MCP server on http://localhost:${port}${MCP_PATH} (Bearer token required)`)
})
}
95 changes: 95 additions & 0 deletions examples/mcp-quickstart/src/quickstart.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import 'reflect-metadata'
import { describe, it, before, after } from 'node:test'
import assert from 'node:assert/strict'
import { createServer, type Server } from 'node:http'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { serve } from '@hono/node-server'
import { createNodeHandler } from './node-http.js'
import { createHonoApp } from './hono.js'
import { DEMO_TOKEN } from './server.js'

// Drive a full MCP session (initialize handshake + tools/call) with the real
// SDK client, optionally sending a bearer token on every request.
async function roundTrip(baseUrl: string, token?: string): Promise<{ toolNames: string[]; text: string }> {
const client = new Client({ name: 'quickstart-test', version: '1.0.0' }, { capabilities: {} })
const transport = new StreamableHTTPClientTransport(
new URL(`${baseUrl}/mcp`),
token ? { requestInit: { headers: { Authorization: `Bearer ${token}` } } } : undefined,
)
// `as never`: the SDK Transport type trips exactOptionalPropertyTypes but is
// runtime-compatible (see the package's own acceptance test).
await client.connect(transport as never)
try {
const list = await client.listTools()
const call = await client.callTool({ name: 'greet', arguments: { name: 'Ada' } })
const content = call.content as Array<{ type: string; text: string }>
return { toolNames: list.tools.map((t) => t.name), text: content[0]!.text }
} finally {
await client.close().catch(() => {})
await transport.close().catch(() => {})
}
}

function listen(server: Server): Promise<number> {
return new Promise((resolve) =>
server.listen(0, '127.0.0.1', () => resolve((server.address() as { port: number }).port)))
}

describe('quickstart: node:http (OAuth-protected)', () => {
let server: Server
let baseUrl: string

before(async () => {
server = createServer(createNodeHandler())
baseUrl = `http://127.0.0.1:${await listen(server)}`
})

after(async () => {
await new Promise<void>((r) => { server.close(() => r()); server.closeAllConnections?.() })
})

it('rejects an unauthenticated request with 401 + WWW-Authenticate', async () => {
const res = await fetch(`${baseUrl}/mcp`, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify({
jsonrpc: '2.0', id: 1, method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'x', version: '1' } },
}),
})
assert.equal(res.status, 401)
assert.match(res.headers.get('www-authenticate') ?? '', /invalid_token/)
await res.text()
})

it('serves tools/call with a valid bearer token (DI resolved)', async () => {
const { toolNames, text } = await roundTrip(baseUrl, DEMO_TOKEN)
assert.ok(toolNames.includes('greet'))
assert.match(text, /Hello, Ada!/)
})
})

describe('quickstart: Hono (Fetch transport)', () => {
let server: ReturnType<typeof serve>
let baseUrl: string

before(async () => {
await new Promise<void>((resolve) => {
server = serve({ fetch: createHonoApp().fetch, port: 0 }, (info) => {
baseUrl = `http://127.0.0.1:${info.port}`
resolve()
})
})
})

after(async () => {
await new Promise<void>((r) => (server as unknown as Server).close(() => r()))
})

it('serves the same MCP server mounted on a framework', async () => {
const { toolNames, text } = await roundTrip(baseUrl) // demo Hono mount is unprotected
assert.ok(toolNames.includes('greet'))
assert.match(text, /Hello, Ada!/)
})
})
74 changes: 74 additions & 0 deletions examples/mcp-quickstart/src/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import 'reflect-metadata'
import {
McpServer, McpTool, McpResource, McpPrompt, McpResponse,
Name, Version, Instructions, Description, Handle,
createResolver,
type McpResolver, type VerifyToken,
} from '@gemstack/mcp'
import { z } from 'zod'

// A plain service. No framework, no container, no AI runtime. The tool below
// asks for it by type via @Handle, and the server's resolver provides it.
export class Greeter {
greet(name: string): string {
return `Hello, ${name}! Served by @gemstack/mcp with zero framework.`
}
}

@Description('Greet someone by name')
class GreetTool extends McpTool {
schema() {
return z.object({ name: z.string().describe('Who to greet') })
}

// @Handle injects the Greeter (resolved from the server's resolver) after the
// validated input. The token is explicit, so no decorator metadata is needed.
@Handle(Greeter)
async handle(input: { name: string }, greeter: Greeter) {
return McpResponse.text(greeter.greet(input.name))
}
}

@Description('The server version, exposed as a readable resource')
class VersionResource extends McpResource {
uri() { return 'info://version' }
async handle() { return '1.0.0' }
}

@Description('A reusable greeting prompt')
class GreetingPrompt extends McpPrompt {
arguments() { return z.object({ name: z.string() }) }
async handle(args: { name: string }) {
return [{ role: 'user' as const, content: `Please greet ${args.name} warmly.` }]
}
}

@Name('quickstart')
@Version('1.0.0')
@Instructions('A demo MCP server: one tool, one resource, one prompt. No Rudder, no AI runtime.')
class QuickstartServer extends McpServer {
protected tools = [GreetTool]
protected resources = [VersionResource]
protected prompts = [GreetingPrompt]
}

// Build a fully-wired server instance. The resolver is INSTANCE-SCOPED: it is
// passed at construction and never read off a global. createResolver() needs no
// DI container; to use one, implement McpResolver = { resolve(token) } over it
// (see the README for an Awilix/tsyringe adapter).
export function makeServer(): McpServer {
const resolver: McpResolver = createResolver().register(Greeter, new Greeter())
return new QuickstartServer({ resolver })
}

// ─── OAuth 2.1 ────────────────────────────────────────────
// The core is auth-agnostic: you supply verifyToken. Here we accept a single
// demo token and grant it the read scope. In production, validate the JWT
// (signature, expiry, revocation) and return its real claims, or null/throw.
export const REQUIRED_SCOPES = ['mcp.read']
export const DEMO_TOKEN = 'demo-token'

export const verifyToken: VerifyToken = (jwt) => {
if (jwt === DEMO_TOKEN) return { sub: 'demo-user', scopes: ['mcp.read'] }
return null
}
5 changes: 5 additions & 0 deletions examples/mcp-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/mcp-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"]
}
28 changes: 26 additions & 2 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,20 @@ 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.
`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>`):

```ts
import { Hono } from 'hono'
import { createWebRequestHandler } from '@gemstack/mcp/runtime'

const handler = createWebRequestHandler(new DemoServer())
const app = new Hono()
app.all('/mcp', (c) => handler(c.req.raw))
```

For a CLI/stdio server, use `startStdio` from the same subpath.

> **Runnable example:** [`examples/mcp-quickstart`](../../examples/mcp-quickstart) is a complete, framework-neutral server (tool + resource + prompt, `@Handle` DI, OAuth 2.1) served over both `node:http` and Hono, with a CI smoke test, and **zero `@rudderjs/*` packages**.

### Resources and prompts

Expand Down Expand Up @@ -103,7 +116,18 @@ 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`.
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 }`:

```ts
import { createContainer, asValue } from 'awilix'
import type { McpResolver } from '@gemstack/mcp'

const container = createContainer().register({ logger: asValue(new Logger()) })
const resolver: McpResolver = { resolve: (token) => container.resolve((token as { name: string }).name) }
new LogServer({ resolver })
```

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

Expand Down
Loading
Loading