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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ pnpm-debug.log*
.env.local
.env.*.local
.nlbrc.json
.dev.vars
.dev.vars.*
.wrangler/

# IDE & OS
.DS_Store
Expand Down
99 changes: 93 additions & 6 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ npx @nextlevelbuilder/mcp
---

### 2. Cloudflare Workers Transport (Edge Deployment)
Hosted endpoint: **https://mcp.nextlevelbuilder.io/mcp**. Check service status at [health](https://mcp.nextlevelbuilder.io/health).

The package exports a pure Web-standard fetch handler compatible with Cloudflare Workers without Node-only dependencies:

```typescript
Expand All @@ -52,10 +54,95 @@ export default {
```

Endpoints supported:
- `POST /mcp` or `POST /`: Direct JSON-RPC requests.
- `GET /sse`: Server-Sent Events stream initialization returning message endpoint.
- `POST /mcp` or `POST /`: Stateless Streamable HTTP JSON-RPC requests; notifications return `202` with an empty body.
- `GET /mcp` and `DELETE /mcp`: `405` (no persistent stream or session).
- `GET /sse`: Legacy SSE stream initialization. Its in-memory sessions require the same isolate, so use `/mcp` on Cloudflare.
- `GET /health`: Diagnostic status endpoint.

The server negotiates protocol `2025-06-18` and retains `2024-11-05` for legacy clients. HTTP clients send `Content-Type: application/json`, `Accept: application/json, text/event-stream`, and the negotiated `MCP-Protocol-Version` on subsequent requests. The Worker does not issue a session ID.

#### Deploy from this repository

Requires Node.js 22+ and pnpm. Run from the repository root:

```bash
pnpm install --frozen-lockfile
pnpm --filter @nextlevelbuilder/mcp exec wrangler login
pnpm --filter @nextlevelbuilder/mcp deploy:check
pnpm --filter @nextlevelbuilder/mcp deploy
```

[`packages/mcp/wrangler.jsonc`](../packages/mcp/wrangler.jsonc) owns the Worker name, Cloudflare account, custom domain, entry point, and upstream API URL. The configured account owns the `nextlevelbuilder.io` zone. The `custom_domain` route lets Cloudflare manage DNS and HTTPS for `mcp.nextlevelbuilder.io`. The build command builds the shared contracts before bundling the Worker. To deploy elsewhere, update both `account_id` and `routes` for the destination zone. Wrangler also prints the deployed `workers.dev` URL; the MCP endpoint is `<worker-url>/mcp`.

For local development, run `pnpm --filter @nextlevelbuilder/mcp dev:worker` (default port 8787).

#### Authentication and browser access

Public directory reads are available without authentication. Remote mutations (`submit_product`, `cast_vote`, `upload_media`, `create_checkout`, `create_api_key`, `revoke_api_key`) require an OAuth access token with the appropriate scope or `Authorization: Bearer <WORKER_AUTH_TOKEN>`. For legacy static-token clients, set a strong token as a Cloudflare secret to enable authorized writes:

```bash
pnpm --filter @nextlevelbuilder/mcp exec wrangler secret put WORKER_AUTH_TOKEN
```

With static-token authentication, each tool still requires its upstream credentials (`api_key` or `session_cookie`) when applicable. An optional `NLB_API_KEY` Worker secret supplies a default API key for tools that use it. OAuth calls use the signed-in account as described below. Never put credentials in Wrangler `vars` or commit them. Local development secrets belong in `packages/mcp/.dev.vars`, which is ignored by git.

Requests without `Origin` (native MCP clients) are accepted. Browser origins must match the Worker origin or the comma-separated `NLB_ALLOWED_ORIGINS` variable; configure exact trusted origins if using a browser client.

Remote client configuration (Cursor):

```json
{
"mcpServers": {
"nlb-directory": {
"url": "https://mcp.nextlevelbuilder.io/mcp",
"headers": {
"Authorization": "Bearer <WORKER_AUTH_TOKEN>"
}
}
}
}
```

Omit `headers` for read-only access. Streamable HTTP behavior follows the [MCP transport specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports).

### 3. OAuth with an NLB account

The checked-in Worker configuration enables OAuth with `NLB_OAUTH_ENABLED=true` for production and staging. Deployments require the matching web provider, database migration and shared delegation secret to be ready first; the configuration alone does not verify live availability. Existing static-token and stdio configurations remain supported.

When enabled, clients discover the web authorization server through `/.well-known/oauth-protected-resource/mcp` (also available at the well-known root). Its issuer is `https://nextlevelbuilder.io/api/auth`. The web uses existing NLB sign-in methods and asks the user to approve the requesting application. Public clients use Authorization Code with S256 PKCE; dynamic client registration supports existing MCP clients. Clients requesting `offline_access` receive refresh tokens.

| Scope | Permission |
| --- | --- |
| `mcp:read` | Read directory data and validate documents |
| `mcp:write` | Submit products, upload media, vote, create checkout links |
| `mcp:keys` | List, create and revoke the user's API keys; newly created keys can outlive this OAuth grant |

Unauthenticated public reads remain available. Protected calls return HTTP `401` with OAuth discovery information, and an authenticated request missing the required scope returns `403`. Start sign-in using your client's OAuth login feature or when it encounters a protected call. An OAuth client needs only the endpoint configuration:

```json
{
"mcpServers": {
"nlb-directory": { "url": "https://mcp.nextlevelbuilder.io/mcp" }
}
}
```

OAuth calls use the signed-in account. Do not pass `api_key`, `session_cookie`, or a different `api_url`. The Worker validates access-token type, signature, issuer, resource audience, expiry and scopes. It sends a separate assertion lasting at most 60 seconds to opted-in NLB API routes, bound to the user, scope, HTTP method and path. OAuth access tokens and web session cookies are not forwarded. The web reloads current user status and retains organization/ownership checks; voting still needs a valid Turnstile token when the web requires one.

Access tokens last five minutes. Manage and disconnect authorized clients at the web's `/oauth/connections` page. Disconnecting revokes that user's refresh-token families for the client and removes consent. Pending authorization codes are bound to that consent and cannot be exchanged after disconnect, even if the user later reconnects. Already issued JWTs can remain valid until their five-minute expiry. Revoking OAuth consent does not revoke API keys previously created through `mcp:keys`; revoke those keys separately.

#### Rollout order

1. In the web repository, generate/review the OAuth schema migration, apply it to staging using the direct database connection, and deploy the web changes through CI. Follow its `docs/deployment-guide.md` for production migration and deployment.
2. Configure `MCP_OAUTH_RESOURCE` on the web and `NLB_OAUTH_RESOURCE` on this Worker to the same canonical MCP endpoint. The web's `BETTER_AUTH_URL` and Worker's `NLB_OAUTH_ISSUER` must identify the same issuer (`<web-origin>/api/auth`); `NLB_API_URL` must match that web origin.
3. Provision the same independently generated, high-entropy `MCP_DELEGATION_SECRET` (at least 32 bytes) on both Workers. Keep it separate from `BETTER_AUTH_SECRET` and `WORKER_AUTH_TOKEN`, and out of source control.
4. Enable the web provider with `MCP_OAUTH_ENABLED=true`. Verify authorization-server metadata, JWKS, sign-in/consent, PKCE and refresh on staging before production.
5. Deploy this Worker with `NLB_OAUTH_ENABLED=true` (already set in the checked-in configuration). Verify discovery, an OAuth-authorized call and a denied call on the custom domain. Keep staging and production resources/secrets isolated.

The `staging` Wrangler environment uses `https://staging.nextlevelbuilder.io` and the separate MCP resource `https://nlb-directory-mcp-staging.digitop-vn.workers.dev/mcp`. Use `pnpm --filter @nextlevelbuilder/mcp deploy --env staging` to deploy it, and add `--env staging` to Wrangler secret commands when provisioning its independent secrets. Production commands omit `--env staging` and use the custom domain.

To disable new OAuth access, set `NLB_OAUTH_ENABLED=false` and deploy the MCP Worker. Legacy static-token callers continue working. Web/schema rollback follows the web repository's forward-only migration policy.

---

## MCP Tools Reference
Expand Down Expand Up @@ -105,7 +192,7 @@ Probes database and service runtime health. Sanitizes output to prevent internal
### 9. `cast_vote`
Casts an organic community vote for a product.
- **Parameters**: `product_id` (string, required UUID), `turnstile_token` (string, optional), `session_cookie` (string, optional), `api_url` (string, optional).
- **Returns**: Vote registration response. Requires user session cookie.
- **Returns**: Vote registration response. Requires OAuth `mcp:write` or a user session cookie.

### 10. `upload_media`
Uploads an image (up to 10MB) or video (up to 100MB) with strict MIME allowlist and size validation.
Expand All @@ -125,14 +212,14 @@ Returns layout templates (SaaS Launch, AI Agent, Dev Tool, Community Curated, Mi
### 13. `list_api_keys`
Lists active developer API keys for the current account.
- **Parameters**: `session_cookie` (string, optional), `api_url` (string, optional).
- **Returns**: `{ success: boolean, data: Array<{ id, name, prefix, enabled, createdAt, expiresAt }> }`. Requires user session cookie.
- **Returns**: `{ success: boolean, data: Array<{ id, name, prefix, enabled, createdAt, expiresAt }> }`. Requires OAuth `mcp:keys` or a user session cookie.

### 14. `create_api_key`
Creates a new developer API key. The raw secret key is returned only once.
- **Parameters**: `name` (string, required), `organization_id` (string, optional), `expires_days` (number, optional), `session_cookie` (string, optional), `api_url` (string, optional).
- **Returns**: `{ success: boolean, data: { id, name, prefix, key, expiresAt, createdAt } }`. Requires user session cookie.
- **Returns**: `{ success: boolean, data: { id, name, prefix, key, expiresAt, createdAt } }`. Requires OAuth `mcp:keys` or a user session cookie.

### 15. `revoke_api_key`
Revokes an existing developer API key by ID.
- **Parameters**: `id` (string, required), `session_cookie` (string, optional), `api_url` (string, optional).
- **Returns**: `{ success: boolean, message: string }`. Requires user session cookie.
- **Returns**: `{ success: boolean, message: string }`. Requires OAuth `mcp:keys` or a user session cookie.
7 changes: 6 additions & 1 deletion packages/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,22 @@
}
},
"scripts": {
"dev:worker": "wrangler dev",
"deploy": "wrangler deploy",
"deploy:check": "wrangler deploy --dry-run",
"build": "tsc -p tsconfig.build.json && node -e \"try { fs.chmodSync('bin/mcp-server.js', 0o755); } catch {}\"",
"type-check": "tsc --noEmit",
"clean": "node -e \"try { fs.rmSync('dist', { recursive: true, force: true }); } catch {}\""
},
"dependencies": {
"@nextlevelbuilder/contracts": "workspace:*",
"jose": "6.2.10",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.10.10",
"typescript": "^5.7.3"
"typescript": "^5.7.3",
"wrangler": "4.131.1"
},
"publishConfig": {
"access": "public"
Expand Down
146 changes: 146 additions & 0 deletions packages/mcp/src/oauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { createRemoteJWKSet, jwtVerify, SignJWT } from "jose";
import { MCP_SCOPES, requiredToolScope, type McpScope, type ToolContext } from "./tool-context.js";

export interface OAuthEnv {
NLB_OAUTH_ENABLED?: string;
NLB_OAUTH_ISSUER?: string;
NLB_OAUTH_RESOURCE?: string;
MCP_DELEGATION_SECRET?: string;
NLB_API_URL?: string;
}

interface OAuthConfig {
issuer: string;
resource: string;
apiUrl: string;
secret: Uint8Array;
}

function canonicalUrl(value: string): string {
const url = new URL(value);
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
if ((url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) || url.username || url.password || url.search || url.hash) {
throw new Error("OAuth configuration requires HTTPS URLs (HTTP loopback is allowed for development)");
}
return url.href.replace(/\/$/, "");
}

function getConfig(env: OAuthEnv): OAuthConfig {
const issuer = canonicalUrl(env.NLB_OAUTH_ISSUER || "https://nextlevelbuilder.io/api/auth");
const resource = canonicalUrl(env.NLB_OAUTH_RESOURCE || "https://mcp.nextlevelbuilder.io/mcp");
const apiUrl = canonicalUrl(env.NLB_API_URL || "https://nextlevelbuilder.io");
const secret = new TextEncoder().encode(env.MCP_DELEGATION_SECRET || "");
if (secret.byteLength < 32 || apiUrl !== new URL(apiUrl).origin || new URL(issuer).origin !== apiUrl) {
throw new Error("OAuth requires a delegation secret and matching issuer/API origins");
}
return { issuer, resource, apiUrl, secret };
}

export function oauthMetadata(env: OAuthEnv): Response {
try {
const config = getConfig(env);
return Response.json({
resource: config.resource,
authorization_servers: [config.issuer],
scopes_supported: [...MCP_SCOPES, "offline_access"],
bearer_methods_supported: ["header"],
resource_name: "Next Level Builders MCP"
});
} catch {
return Response.json({ error: "OAuth is not configured" }, { status: 503 });
}
}

function challenge(config: OAuthConfig, status: 401 | 403, scope: McpScope, invalidToken = false): Response {
const metadataUrl = new URL(`/.well-known/oauth-protected-resource${new URL(config.resource).pathname}`, config.resource);
const error = status === 403 ? "insufficient_scope" : invalidToken ? "invalid_token" : undefined;
return Response.json({ error: error || "unauthorized" }, {
status,
headers: {
"WWW-Authenticate": `Bearer resource_metadata="${metadataUrl}", scope="${scope} offline_access"${error ? `, error="${error}"` : ""}`,
"Cache-Control": "no-store"
}
});
}

// Cache only public signing keys. Identity and delegation state are request-local.
const keySets = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
function getKeySet(issuer: string) {
let keys = keySets.get(issuer);
if (!keys) {
keys = createRemoteJWKSet(new URL(`${issuer}/jwks`), { timeoutDuration: 5000 });
keySets.set(issuer, keys);
}
return keys;
}

function delegatedFetch(config: OAuthConfig, subject: string, expiresAt: number, scope: McpScope): typeof fetch {
return async (input, init) => {
const request = new Request(input, init);
const url = new URL(request.url);
if (url.origin !== config.apiUrl || !url.pathname.startsWith("/api/")) {
throw new Error("OAuth calls must use the configured NLB API");
}
const now = Math.floor(Date.now() / 1000);
if (expiresAt <= now) throw new Error("OAuth access token expired");
const headers = new Headers(request.headers);
headers.delete("Authorization");
headers.delete("Cookie");
headers.delete("x-api-key");
headers.delete("X-NLB-MCP-Assertion");
if (scope !== "mcp:read") {
const assertion = await new SignJWT({ scope, method: request.method, path: url.pathname })
.setProtectedHeader({ alg: "HS256", typ: "nlb-mcp-delegation+jwt" })
.setIssuer(config.resource).setAudience(`${config.apiUrl}/api`).setSubject(subject)
.setJti(crypto.randomUUID()).setIssuedAt(now).setExpirationTime(Math.min(now + 60, expiresAt))
.sign(config.secret);
headers.set("X-NLB-MCP-Assertion", assertion);
}
// Never forward credentials across an upstream redirect.
return fetch(new Request(request, { headers, redirect: "error" }));
};
}

export async function authorizeOAuthMessage(
request: Request,
message: unknown,
env: OAuthEnv,
legacyContext: ToolContext
): Promise<ToolContext | Response> {
if (env.NLB_OAUTH_ENABLED !== "true" || legacyContext.workerAuth) return legacyContext;
let config: OAuthConfig;
try { config = getConfig(env); } catch {
return Response.json({ error: "OAuth is not configured" }, { status: 503 });
}
const rpc = message as { method?: unknown; params?: { name?: unknown } } | null;
const toolName = rpc?.method === "tools/call" && typeof rpc.params?.name === "string" ? rpc.params.name : "";
const scope = requiredToolScope(toolName);
const authorization = request.headers.get("Authorization");
if (!authorization) {
return toolName && scope !== "mcp:read" ? challenge(config, 401, scope) : legacyContext;
}
const token = /^Bearer ([^\s]+)$/i.exec(authorization)?.[1];
if (!token) return challenge(config, 401, scope, true);
try {
const { payload } = await jwtVerify(token, getKeySet(config.issuer), {
issuer: config.issuer,
audience: config.resource,
algorithms: ["RS256", "ES256", "EdDSA"],
typ: "at+jwt",
requiredClaims: ["sub", "exp", "iat", "scope"],
maxTokenAge: 300
});
if (!payload.sub || typeof payload.scope !== "string" || !payload.exp || !payload.iat || payload.exp - payload.iat > 300) {
return challenge(config, 401, scope, true);
}
const scopes = payload.scope.split(/\s+/);
if (toolName && !scopes.includes(scope)) return challenge(config, 403, scope);
return {
env: { NLB_API_URL: config.apiUrl },
oauth: { subject: payload.sub, scopes },
fetch: delegatedFetch(config, payload.sub, payload.exp, scope)
};
} catch {
return challenge(config, 401, scope, true);
}
}
24 changes: 20 additions & 4 deletions packages/mcp/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { TOOLS, McpToolDefinition } from "./tools/index.js";
import { requiredToolScope, type ToolContext } from "./tool-context.js";

export const SUPPORTED_PROTOCOL_VERSIONS = ["2025-06-18", "2024-11-05"] as const;
export const SERVER_VERSION = "0.2.0";

export interface JsonRpcRequest {
jsonrpc: "2.0";
Expand Down Expand Up @@ -46,7 +50,7 @@ export class McpServer {

public async handleMessage(
rawMessage: unknown,
context?: { workerAuth?: boolean; env?: { NLB_API_KEY?: string; NLB_API_URL?: string } }
context?: ToolContext
): Promise<JsonRpcResponse | null> {
if (typeof rawMessage !== "object" || rawMessage === null) {
return {
Expand Down Expand Up @@ -79,11 +83,14 @@ export class McpServer {

switch (req.method) {
case "initialize": {
const requestedVersion = req.params?.protocolVersion;
const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.find((version) => version === requestedVersion)
?? SUPPORTED_PROTOCOL_VERSIONS[0];
const result = {
protocolVersion: "2024-11-05",
protocolVersion,
serverInfo: {
name: "nlb-directory-mcp",
version: "0.1.0"
version: SERVER_VERSION
},
capabilities: {
tools: {
Expand Down Expand Up @@ -137,7 +144,10 @@ export class McpServer {
error: { code: -32601, message: `Tool not found: ${toolName}` }
};
}
if (context && context.workerAuth !== true && MUTATING_TOOLS[toolName]) {
if (context?.oauth && !context.oauth.scopes.includes(requiredToolScope(toolName))) {
return isNotification ? null : { jsonrpc: "2.0", id, error: { code: -32001, message: "Insufficient OAuth scope" } };
}
if (context && !context.oauth && context.workerAuth !== true && MUTATING_TOOLS[toolName]) {
return isNotification
? null
: {
Expand All @@ -151,6 +161,12 @@ export class McpServer {
}

try {
if (context?.oauth) {
if (toolArgs.api_key !== undefined || toolArgs.session_cookie !== undefined ||
(toolArgs.api_url !== undefined && toolArgs.api_url !== context.env?.NLB_API_URL)) {
throw new Error("OAuth calls cannot override API URL or user credentials");
}
}
const output = await tool.handler(toolArgs, context);
return isNotification
? null
Expand Down
Loading
Loading