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

Quality + docs pass for mcp:

- OAuth: reject an empty bearer token (`Authorization: Bearer ` with no value) up front with a `401 invalid_token` instead of forwarding an empty string to `verifyToken`.
- Errors thrown when a `@Handle` dependency fails to resolve now chain the original via `{ cause }`.
- Documented `McpResponse.text/json/error` (and when to prefer `error()` over throwing); neutralized framework-specific wording in the OAuth core docs.
- README: completed the OAuth 2.1 section (a real `jose`-based `verifyToken`, and that `oauth2McpMiddleware` + `registerOAuth2Metadata` must both be wired), softened the origin framing.
39 changes: 29 additions & 10 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

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`.
It is standalone and dependency-light: its only runtime dependencies are `@modelcontextprotocol/sdk`, `zod`, and `reflect-metadata`. (It graduated from the mature `@rudderjs/mcp` server framework, re-versioned under the GemStack umbrella.)

## Which MCP package do I want?

Expand Down Expand Up @@ -70,7 +70,7 @@ 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**.
> **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 framework dependencies**.

### Resources and prompts

Expand Down Expand Up @@ -131,21 +131,40 @@ If a `@Handle` method requests a dependency and no resolver is provided — or t

## 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.
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. Back it with any JWT library (`jose` shown here), a token-introspection endpoint, or a framework's auth integration.

Two pieces work together, and you need **both**:

1. `oauth2McpMiddleware('/mcp', ...)` guards the MCP endpoint and, on failure, returns an RFC 9728 `WWW-Authenticate` challenge.
2. `registerOAuth2Metadata(router, '/mcp', ...)` serves the protected-resource metadata document at `/.well-known/oauth-protected-resource/mcp` that the challenge points clients to. Without it, compliant clients can't discover the authorization server.

```ts
import { oauth2McpMiddleware } from '@gemstack/mcp'
import { oauth2McpMiddleware, registerOAuth2Metadata } from '@gemstack/mcp'
import { createRemoteJWKSet, jwtVerify } from 'jose'

const JWKS = createRemoteJWKSet(new URL('https://issuer.example.com/.well-known/jwks.json'))

const mw = oauth2McpMiddleware('/mcp', {
const options = {
scopes: ['mcp.read'],
verifyToken: async (jwt) => {
// validate however you like; return claims or null
return { sub: 'user-1', scopes: ['mcp.read'] }
scopesSupported: ['mcp.read', 'mcp.write'],
authorizationServers: ['https://issuer.example.com'],
verifyToken: async (jwt: string) => {
try {
const { payload } = await jwtVerify(jwt, JWKS, { audience: 'https://api.example.com/mcp' })
// map your token's claims onto { sub?, scopes? }
return { sub: payload.sub, scopes: String(payload['scope'] ?? '').split(' ').filter(Boolean) }
} catch {
return null // invalid/expired -> 401
}
},
})
}

// Express/Connect-style wiring:
app.use('/mcp', oauth2McpMiddleware('/mcp', options))
registerOAuth2Metadata(app, '/mcp', options)
```

On success the verified claims are attached to the request as `req.mcpAuth`. `registerOAuth2Metadata(...)` emits the RFC 9728 protected-resource metadata document.
On success the verified claims are attached to the request as `req.mcpAuth` (`{ sub?, scopes?, claims }`). Missing required `scopes` yields a `403 insufficient_scope`; a missing/invalid token yields `401 invalid_token`.

## Testing

Expand Down
12 changes: 12 additions & 0 deletions packages/mcp/src/McpResponse.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
import type { McpToolResult } from './McpTool.js'

/**
* Helpers for building a tool's {@link McpToolResult}. Return one of these from
* a tool's `handle()`.
*/
export class McpResponse {
/** A plain-text result. */
static text(content: string): McpToolResult {
return { content: [{ type: 'text', text: content }] }
}

/** A structured result, serialized as pretty-printed JSON text. */
static json(data: unknown): McpToolResult {
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }
}

/**
* An error result (`isError: true`), prefixed with `Error: `. The client sees
* a failed tool call rather than a thrown exception, so prefer this for
* expected, user-facing failures (validation, not-found) and reserve throwing
* for unexpected faults.
*/
static error(message: string): McpToolResult {
return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true }
}
Expand Down
17 changes: 11 additions & 6 deletions packages/mcp/src/auth/oauth2.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
/**
* OAuth 2.1 bearer-token protection for an MCP web endpoint, framework-agnostic.
*
* The core does NOT know how to verify a token — that is the binding's / app's
* job. Supply a {@link VerifyToken} via {@link OAuth2McpOptions.verifyToken}: it
* validates the JWT (signature, expiry, revocation — whatever your authorization
* server requires) and returns the token's claims, or `null` / throws when the
* token is invalid. The Rudder binding wires `@rudderjs/passport` here; a
* non-Rudder app supplies its own verifier.
* The core does NOT know how to verify a token — that is the app's job. Supply
* a {@link VerifyToken} via {@link OAuth2McpOptions.verifyToken}: it validates
* the JWT (signature, expiry, revocation — whatever your authorization server
* requires) and returns the token's claims, or `null` / throws when the token
* is invalid. Back it with any JWT library (e.g. `jose`), a hosted introspection
* endpoint, or a framework's auth integration.
*
* On failure the middleware adds an RFC 9728 `WWW-Authenticate` header pointing
* clients at the protected-resource metadata document (see
Expand Down Expand Up @@ -93,6 +93,11 @@ export function oauth2McpMiddleware(mcpPath: string, options: OAuth2McpOptions =
}

const jwt = authHeader.slice(7).trim()
if (!jwt) {
challenge(res, metadataUrl, 'invalid_token', 'Bearer token required.')
return
}

let claims: VerifiedToken | null
try {
claims = await verifyToken(jwt)
Expand Down
17 changes: 16 additions & 1 deletion packages/mcp/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1251,7 +1251,7 @@ describe('oauth2McpMiddleware — happy paths', () => {
}
}

// A stand-in for the binding's verifier (the Rudder binding wires passport here).
// A stand-in for an app-supplied verifier (real ones validate the JWT).
function verifier(opts: { scopes?: string[]; sub?: string } = {}): VerifyToken {
return async () => ({
sub: opts.sub ?? 'user-1',
Expand Down Expand Up @@ -1327,6 +1327,21 @@ describe('oauth2McpMiddleware — happy paths', () => {
assert.equal(calls.status, 401)
assert.ok(calls.headers['www-authenticate']?.includes('invalid_token'))
})

it('an empty bearer token ("Bearer ") is rejected without calling the verifier', async () => {
const { oauth2McpMiddleware } = await import('./auth/oauth2.js')
let verifierCalled = false
const mw = oauth2McpMiddleware('/mcp/secure', {
verifyToken: async () => { verifierCalled = true; return { sub: 'x' } },
})
const { res, calls } = mockRes()
let nextCalled = false
await mw(mockReq('Bearer ') as never, res as never, async () => { nextCalled = true })
assert.equal(nextCalled, false)
assert.equal(verifierCalled, false)
assert.equal(calls.status, 401)
assert.ok(calls.headers['www-authenticate']?.includes('invalid_token'))
})
})

describe('registerOAuth2Metadata', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/src/runtime/handle-deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export function resolveHandleDeps(
const msg = err instanceof Error ? err.message : String(err)
throw new Error(
`[gemstack/mcp] failed to resolve dependency ${describeToken(token)} for ${member}: ${msg}`,
{ cause: err },
)
}
if (resolved === undefined) {
Expand Down
Loading