Skip to content
Open
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
29 changes: 29 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# ─── Meta Ads ────────────────────────────────────────────────────────────────
# Required: Meta (Facebook) User Access Token with ads_read permission
META_ADS_ACCESS_TOKEN=your_meta_access_token_here

# ─── Transport ────────────────────────────────────────────────────────────────
# "stdio" (default) or "http"
TRANSPORT=http
PORT=3000

# ─── Scalekit OAuth 2.1 ──────────────────────────────────────────────────────
# Set OAUTH_ENABLED=true to require valid Scalekit JWT on all /mcp requests.
# Leave unset or set to false for local dev / stdio mode.
OAUTH_ENABLED=true

# From Scalekit dashboard → API Credentials
SCALEKIT_ENVIRONMENT_URL=https://<your-env>.scalekit.cloud
SCALEKIT_CLIENT_ID=skc_...
SCALEKIT_CLIENT_SECRET=sks_...

# Your MCP server's public URL (used as OAuth resource identifier + audience claim).
# Must match the "Server URL" configured in Scalekit dashboard → MCP Servers.
MCP_SERVER_URL=https://mcp.yourapp.com

# Autogenerated resource ID from Scalekit dashboard (only needed if MCP_SERVER_URL is NOT set).
# SCALEKIT_RESOURCE_ID=res_...

# Comma-separated list of OAuth scopes your server advertises.
# These must match the scopes defined in Scalekit dashboard → MCP Servers → Scopes.
MCP_SCOPES=meta_ads:read
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,62 @@ TRANSPORT=http META_ADS_ACCESS_TOKEN=YOUR_TOKEN PORT=8080 node dist/index.js
**Endpoints:**
- `POST /mcp` — MCP protocol endpoint
- `GET /health` — Health check (`{"status":"ok"}`)
- `GET /.well-known/oauth-protected-resource` — OAuth 2.1 discovery (RFC 9728)

## OAuth 2.1 Authentication (Scalekit)

For production deployments, secure the HTTP server with [Scalekit](https://scalekit.com) OAuth 2.1. This enables Claude Desktop, Cursor, VS Code, and any MCP-compliant client to authenticate users via social login or enterprise SSO before accessing your tools.

### Setup

1. Create a free [Scalekit account](https://app.scalekit.com/ws/signup)
2. In the dashboard → **MCP Servers** → **Add MCP server**:
- Enable **Dynamic Client Registration** and **CIMD**
- Add scopes (e.g., `meta_ads:read`)
- Set **Server URL** to your public MCP server URL
3. Copy credentials to your environment:

```bash
TRANSPORT=http \
OAUTH_ENABLED=true \
SCALEKIT_ENVIRONMENT_URL=https://<env>.scalekit.cloud \
SCALEKIT_CLIENT_ID=skc_... \
SCALEKIT_CLIENT_SECRET=sks_... \
MCP_SERVER_URL=https://mcp.yourapp.com \
MCP_SCOPES=meta_ads:read \
META_ADS_ACCESS_TOKEN=YOUR_META_ACCESS_TOKEN \
node dist/index.js
```

Copy `.env.example` to `.env` and fill in your values, then run with `node -r dotenv/config dist/index.js`.

### Environment Variables

| Variable | Required | Description |
|----------|----------|-------------|
| `OAUTH_ENABLED` | No | Set `true` to enable JWT validation on all `/mcp` requests |
| `SCALEKIT_ENVIRONMENT_URL` | When OAuth enabled | Your Scalekit environment URL |
| `SCALEKIT_CLIENT_ID` | When OAuth enabled | Scalekit client ID |
| `SCALEKIT_CLIENT_SECRET` | When OAuth enabled | Scalekit client secret |
| `MCP_SERVER_URL` | Recommended | Public URL of this server (used as OAuth audience) |
| `SCALEKIT_RESOURCE_ID` | Fallback | Autogenerated resource ID if `MCP_SERVER_URL` is not set |
| `MCP_SCOPES` | No | Comma-separated advertised scopes (default: `meta_ads:read`) |

### How it works

```
MCP Client (Claude/Cursor)
├─ GET /.well-known/oauth-protected-resource ← discovers Scalekit as auth server
├─ OAuth flow via Scalekit (PKCE + DCR/CIMD)
│ └─ Returns short-lived JWT (300–3600s)
└─ POST /mcp Authorization: Bearer <jwt> ← validated by authMiddleware
└─ scalekit.validateToken(jwt, { audience })
```

OAuth is **opt-in**: without `OAUTH_ENABLED=true` the server runs unauthenticated (suitable for local stdio use).

### Claude.ai Custom Connector

Expand Down
50 changes: 50 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1",
"@scalekit-sdk/node": "^2.3.0",
"axios": "^1.7.9",
"express": "^5.2.1",
"zod": "^3.23.8"
Expand Down
160 changes: 160 additions & 0 deletions src/auth/scalekit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { Scalekit } from "@scalekit-sdk/node";
import type { NextFunction, Request, Response } from "express";

// ---------------------------------------------------------------------------
// Scalekit client (lazy-initialized so stdio mode never touches these vars)
// ---------------------------------------------------------------------------

let _scalekit: Scalekit | null = null;

function getScalekitClient(): Scalekit {
if (_scalekit) return _scalekit;

const envUrl = process.env.SCALEKIT_ENVIRONMENT_URL;
const clientId = process.env.SCALEKIT_CLIENT_ID;
const clientSecret = process.env.SCALEKIT_CLIENT_SECRET;

if (!envUrl || !clientId || !clientSecret) {
throw new Error(
"OAuth is enabled but Scalekit credentials are missing. " +
"Set SCALEKIT_ENVIRONMENT_URL, SCALEKIT_CLIENT_ID, and SCALEKIT_CLIENT_SECRET."
);
}

_scalekit = new Scalekit(envUrl, clientId, clientSecret);
return _scalekit;
}

// ---------------------------------------------------------------------------
// OAuth resource metadata
// ---------------------------------------------------------------------------

/**
* The resource identifier for this MCP server.
* Falls back to the autogenerated resource_id from the Scalekit dashboard if
* MCP_SERVER_URL is not set.
*/
export function getResourceId(): string {
return process.env.MCP_SERVER_URL ?? process.env.SCALEKIT_RESOURCE_ID ?? "";
}

export function getMetadataEndpoint(): string {
const base = process.env.MCP_SERVER_URL ?? "";
return `${base}/.well-known/oauth-protected-resource`;
}

/** Scopes advertised by this MCP server (comma-separated env var). */
export function getScopesSupported(): string[] {
return (process.env.MCP_SCOPES ?? "meta_ads:read").split(",").map((s) => s.trim());
}

/** Build the OAuth Protected Resource Metadata document (RFC 9728). */
export function buildResourceMetadata() {
const envUrl = process.env.SCALEKIT_ENVIRONMENT_URL ?? "";
const resourceId = getResourceId();
const scaleKitResourceId = process.env.SCALEKIT_RESOURCE_ID ?? resourceId;

return {
resource: resourceId,
authorization_servers: [`${envUrl}/resources/${scaleKitResourceId}`],
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong authorization_servers URL format in OAuth metadata

High Severity

The authorization_servers URL is constructed as ${envUrl}/resources/${scaleKitResourceId}, but per Scalekit's own documentation, it simply needs to be the environment URL (e.g., ["https://your-org.scalekit.com"]). Additionally, when SCALEKIT_RESOURCE_ID is not set, scaleKitResourceId falls back to resourceId (the full MCP_SERVER_URL), producing a malformed URL like https://env.scalekit.cloud/resources/https://mcp.yourapp.com. MCP clients consuming this discovery metadata will fail to locate the correct authorization server.

Additional Locations (1)

Fix in Cursor Fix in Web

bearer_methods_supported: ["header"],
resource_documentation: `${resourceId}/docs`,
scopes_supported: getScopesSupported(),
};
}

// ---------------------------------------------------------------------------
// WWW-Authenticate header helpers
// ---------------------------------------------------------------------------

export const WWWHeader = {
key: "WWW-Authenticate",
value: () =>
`Bearer realm="OAuth", resource_metadata="${getMetadataEndpoint()}"`,
};

// ---------------------------------------------------------------------------
// Express middleware
// ---------------------------------------------------------------------------

/**
* Auth middleware for the HTTP MCP server.
*
* - Bypasses `.well-known` paths (public discovery endpoints).
* - Extracts and validates the Bearer JWT issued by Scalekit.
* - Returns 401 with a proper WWW-Authenticate header on failure.
*
* Enabled only when OAUTH_ENABLED=true; otherwise passes through (useful for
* local development or stdio mode).
*/
export async function authMiddleware(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
// Allow public discovery + health check without auth
if (req.path.includes(".well-known") || req.path === "/health") {
return next();
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overly broad .well-known path check bypasses auth

Low Severity

The auth bypass check req.path.includes(".well-known") matches any request path containing .well-known as a substring anywhere, not just the specific well-known endpoint prefix. A more precise check like req.path.startsWith("/.well-known") would be safer and match the intended behavior.

Fix in Cursor Fix in Web


try {
const authHeader = req.headers["authorization"];
const token =
authHeader?.startsWith("Bearer ") ? authHeader.slice(7).trim() : null;

if (!token) {
res.status(401).set(WWWHeader.key, WWWHeader.value()).end();
return;
}

const resourceId = getResourceId();
const scalekit = getScalekitClient();

await scalekit.validateToken(token, {
audience: resourceId ? [resourceId] : undefined,
});

next();
} catch {
res.status(401).set(WWWHeader.key, WWWHeader.value()).end();
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Configuration errors silently swallowed as 401 responses

Medium Severity

The catch block in authMiddleware catches all errors — including the configuration error thrown by getScalekitClient() when Scalekit credentials are missing — and silently returns a 401 with no logging. If OAUTH_ENABLED=true but the credentials aren't configured, every request fails with a generic 401 and the operator has zero diagnostic output to understand why. Configuration errors and token-validation errors are fundamentally different but handled identically here.

Fix in Cursor Fix in Web

}

/**
* Scope-checking helper for individual tools that need finer-grained control.
*
* Usage:
* await requireScope(req, res, "meta_ads:write");
* // returns false and sends 403 if scope is missing
*/
export async function requireScope(
req: Request,
res: Response,
scope: string
): Promise<boolean> {
const authHeader = req.headers["authorization"];
const token =
authHeader?.startsWith("Bearer ") ? authHeader.slice(7).trim() : null;

if (!token) {
res.status(401).set(WWWHeader.key, WWWHeader.value()).end();
return false;
}

try {
const resourceId = getResourceId();
const scalekit = getScalekitClient();
await scalekit.validateToken(token, {
audience: resourceId ? [resourceId] : undefined,
requiredScopes: [scope],
});
return true;
} catch {
res.status(403).json({
error: "insufficient_scope",
error_description: `Required scope: ${scope}`,
scope,
});
return false;
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exported requireScope function is never used anywhere

Low Severity

requireScope is exported but never imported or called anywhere in the codebase — it only appears in its own definition and JSDoc comment. This is dead code that adds maintenance burden without providing any functionality.

Fix in Cursor Fix in Web

39 changes: 37 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,15 @@
* node dist/index.js --access-token <YOUR_META_ACCESS_TOKEN>
* META_ADS_ACCESS_TOKEN=<token> node dist/index.js
*
* Usage (remote HTTP):
* Usage (remote HTTP without OAuth):
* TRANSPORT=http META_ADS_ACCESS_TOKEN=<token> node dist/index.js
* TRANSPORT=http META_ADS_ACCESS_TOKEN=<token> PORT=3000 node dist/index.js
*
* Usage (remote HTTP with Scalekit OAuth):
* TRANSPORT=http OAUTH_ENABLED=true \
* SCALEKIT_ENVIRONMENT_URL=https://<env>.scalekit.cloud \
* SCALEKIT_CLIENT_ID=<id> SCALEKIT_CLIENT_SECRET=<secret> \
* MCP_SERVER_URL=https://mcp.yourapp.com \
* META_ADS_ACCESS_TOKEN=<token> node dist/index.js
*/

import { createRequire } from "module";
Expand All @@ -33,6 +39,7 @@ import { registerMediaTools } from "./tools/media.js";
import { registerActivityTools } from "./tools/activities.js";
import { registerPaginationTools } from "./tools/pagination.js";
import { getAccessToken } from "./services/graph-api.js";
import { authMiddleware, buildResourceMetadata, getScopesSupported } from "./auth/scalekit.js";

const server = new McpServer({
name: "meta-ads-mcp-server",
Expand Down Expand Up @@ -79,9 +86,37 @@ async function runHTTP(): Promise<void> {
process.exit(1);
}

const oauthEnabled = process.env.OAUTH_ENABLED === "true";

const app = express();
app.use(express.json());

// OAuth 2.1 resource discovery (RFC 9728) — always public
app.get("/.well-known/oauth-protected-resource", (_req, res) => {
res.json(buildResourceMetadata());
});

// Health check — always public
app.get("/health", (_req, res) => {
res.json({
status: "ok",
server: "meta-ads-mcp-server",
version: "1.1.0",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Health endpoint hardcodes stale version string

Medium Severity

The new /health endpoint hardcodes version: "1.1.0" instead of using the dynamic version variable imported from package.json (currently "1.2.2"). The original health endpoint at line 131 correctly referenced the version variable but is now unreachable dead code because this new route is registered first with the same path.

Fix in Cursor Fix in Web

oauth: oauthEnabled,
scopes: getScopesSupported(),
});
});

// Apply Scalekit JWT validation to all other routes when OAuth is enabled
if (oauthEnabled) {
app.use(authMiddleware);
console.error("OAuth 2.1 (Scalekit) authentication enabled");
} else {
console.error(
"OAuth disabled — set OAUTH_ENABLED=true to require Scalekit JWT validation"
);
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate unreachable /health route is dead code

Low Severity

A second app.get("/health", ...) handler is registered after the new one at line 100. Since Express matches the first registered route for a given path, this second handler is never reached. It appears to be a leftover from the pre-PR code that wasn't removed when the new health endpoint was added.

Fix in Cursor Fix in Web

app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
Expand Down