New:Socket for Asana Is Now Available.Learn more
Get Started

@wcagc/mcp

Package Overview
Dependencies
Maintainers
1
Versions
17
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@wcagc/mcp - npm Package Compare versions

Comparing version
0.3.0
to
0.3.1
+104
-0
dist/auth-verifier.js
import { InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
import { createPublicKey, verify as verifySignature } from "node:crypto";
import { apiJson, McpApiError } from "./api-client.js";

@@ -7,2 +8,3 @@ import { config } from "./config.js";

const cache = new Map();
let jwksCache;
function pruneExpired(now) {

@@ -53,1 +55,103 @@ for (const [token, entry] of cache) {

};
function decodePart(value) {
try {
return JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
}
catch {
throw new InvalidTokenError("Invalid OAuth access token.");
}
}
async function fetchJwks(force = false) {
const now = Date.now() / 1000;
if (!force && jwksCache && now - jwksCache.fetchedAt < config.jwksCacheTtlSeconds) {
return jwksCache.keys;
}
let response;
try {
response = await fetch(config.oauthJwksUrl, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(config.requestTimeoutMs),
});
}
catch {
throw new InvalidTokenError("Could not verify the OAuth access token.");
}
if (!response.ok) {
throw new InvalidTokenError("Could not verify the OAuth access token.");
}
const body = await response.json();
if (!Array.isArray(body.keys)) {
throw new InvalidTokenError("Could not verify the OAuth access token.");
}
jwksCache = { keys: body.keys, fetchedAt: now };
return body.keys;
}
function jwtScopes(claim) {
if (Array.isArray(claim))
return claim.filter((scope) => typeof scope === "string");
return typeof claim === "string" ? claim.split(/\s+/).filter(Boolean) : [];
}
async function verifyJwt(token) {
const parts = token.split(".");
if (parts.length !== 3)
throw new InvalidTokenError("Invalid OAuth access token.");
const [encodedHeader, encodedClaims, encodedSignature] = parts;
const header = decodePart(encodedHeader);
const claims = decodePart(encodedClaims);
if (header.alg !== "RS256" || !header.kid)
throw new InvalidTokenError("Invalid OAuth access token.");
let keys = await fetchJwks();
let key = keys.find((candidate) => candidate.kid === header.kid);
if (!key) {
keys = await fetchJwks(true);
key = keys.find((candidate) => candidate.kid === header.kid);
}
if (!key)
throw new InvalidTokenError("Invalid OAuth access token.");
let signatureValid = false;
try {
signatureValid = verifySignature("RSA-SHA256", Buffer.from(`${encodedHeader}.${encodedClaims}`), createPublicKey({ key, format: "jwk" }), Buffer.from(encodedSignature, "base64url"));
}
catch {
throw new InvalidTokenError("Invalid OAuth access token.");
}
if (!signatureValid)
throw new InvalidTokenError("Invalid OAuth access token.");
const now = Math.floor(Date.now() / 1000);
const audiences = Array.isArray(claims.aud) ? claims.aud : claims.aud ? [claims.aud] : [];
const scopes = jwtScopes(claims.scope);
if (claims.iss !== config.oauthIssuer
|| !audiences.includes(config.mcpServerUrl)
|| typeof claims.exp !== "number"
|| claims.exp <= now
|| (typeof claims.nbf === "number" && claims.nbf > now)
|| !claims.sub
|| !claims.org_id
|| !claims.connection_id
|| !claims.client_id
|| !scopes.includes("mcp:scan")) {
throw new InvalidTokenError("Invalid OAuth access token.");
}
return {
token,
clientId: claims.client_id,
scopes,
expiresAt: claims.exp,
resource: new URL(config.mcpServerUrl),
extra: {
organizationId: claims.org_id,
connectionId: claims.connection_id,
subjectId: claims.sub,
},
};
}
export const oauthJwtVerifier = {
verifyAccessToken: verifyJwt,
};
export const bearerVerifier = {
verifyAccessToken(token) {
return token.startsWith("wcagc_")
? introspectVerifier.verifyAccessToken(token)
: oauthJwtVerifier.verifyAccessToken(token);
},
};
+8
-1

@@ -11,2 +11,5 @@ function num(envVar, fallback) {

}
const apiBaseUrl = process.env.WCAGC_API_BASE_URL ?? "https://api.wcagc.com";
const oauthIssuer = process.env.WCAGC_MCP_OAUTH_ISSUER ?? apiBaseUrl;
const mcpServerUrl = process.env.WCAGC_MCP_URL ?? "https://mcp.wcagc.com/mcp";
/**

@@ -31,3 +34,3 @@ * wcagc-mcp holds no secrets of its own beyond these — it is a thin adapter that translates MCP

// Local development and self-hosting set this explicitly (tests always do).
apiBaseUrl: process.env.WCAGC_API_BASE_URL ?? "https://api.wcagc.com",
apiBaseUrl,
requestTimeoutMs: num(process.env.WCAGC_MCP_REQUEST_TIMEOUT_MS, 15_000),

@@ -37,2 +40,6 @@ // How long a verified introspection result is trusted before the hosted transport re-checks

introspectCacheTtlSeconds: num(process.env.WCAGC_MCP_INTROSPECT_TTL_SECONDS, 60),
oauthIssuer,
oauthJwksUrl: process.env.WCAGC_MCP_OAUTH_JWKS_URL ?? `${oauthIssuer}/oauth2/jwks`,
mcpServerUrl,
jwksCacheTtlSeconds: num(process.env.WCAGC_MCP_JWKS_TTL_SECONDS, 300),
// ── check_pdf url fetch (wcagc-api only accepts multipart bytes — see src/pdf-fetch.ts) ──

@@ -39,0 +46,0 @@ pdfFetchTimeoutMs: num(process.env.WCAGC_MCP_PDF_FETCH_TIMEOUT_MS, 10_000),

import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter, } from "@modelcontextprotocol/sdk/server/auth/router.js";
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";

@@ -6,3 +7,3 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";

import { pathToFileURL } from "node:url";
import { introspectVerifier } from "./auth-verifier.js";
import { bearerVerifier } from "./auth-verifier.js";
import { config } from "./config.js";

@@ -32,7 +33,30 @@ import { logger } from "./logger.js";

});
const issuer = config.oauthIssuer.replace(/\/+$/, "");
const oauthMetadata = {
issuer,
authorization_endpoint: `${issuer}/oauth2/authorize`,
token_endpoint: `${issuer}/oauth2/token`,
jwks_uri: `${issuer}/oauth2/jwks`,
revocation_endpoint: `${issuer}/oauth2/revoke`,
scopes_supported: ["mcp:scan"],
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
token_endpoint_auth_methods_supported: ["none", "client_secret_basic"],
code_challenge_methods_supported: ["S256"],
};
app.use(mcpAuthMetadataRouter({
oauthMetadata,
resourceServerUrl: new URL(config.mcpServerUrl),
serviceDocumentationUrl: new URL("https://wcagc.com/integrations/mcp"),
scopesSupported: ["mcp:scan"],
resourceName: "wcagc MCP",
}));
app.use(mcpApp);
// Resource-server-only bearer auth (no OAuth Authorization Server — spec OQ-2, deferred): the
// bearer IS the org's MCP-scoped API key, verified by forwarding it to wcagc-api's introspect
// endpoint. resourceMetadataUrl / RFC 9728 discovery is intentionally not wired up this wave.
const auth = requireBearerAuth({ verifier: introspectVerifier, requiredScopes: ["mcp:scan"] });
// Hosted clients use OAuth JWTs discovered through RFC 9728. The existing wcagc_ API-key path
// remains supported for local/CI clients and is still introspected by wcagc-api.
const auth = requireBearerAuth({
verifier: bearerVerifier,
requiredScopes: ["mcp:scan"],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(new URL(config.mcpServerUrl)),
});
// Stateless mode (SDK guidance: "suitable for simple API proxies... any server node can process

@@ -39,0 +63,0 @@ // requests" — exactly this service): a fresh McpServer + transport per request. Tool handlers

+1
-1

@@ -9,3 +9,3 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

// test/version.test.ts fails the build if the two ever drift.
export const VERSION = "0.3.0";
export const VERSION = "0.3.1";
/**

@@ -12,0 +12,0 @@ * One server, two transports (hosted Streamable HTTP + local stdio) share this — the tool

{
"name": "@wcagc/mcp",
"version": "0.3.0",
"version": "0.3.1",
"mcpName": "io.github.WCAG-Compliance/mcp",

@@ -5,0 +5,0 @@ "description": "wcagc MCP server \u2014 a thin, stateless adapter that translates MCP tool calls into wcagc-api HTTP calls. No database, no secrets beyond WCAGC_API_BASE_URL (+ WCAGC_MCP_KEY for local stdio mode). Open-source: this package holds no business logic or credentials of its own.",

@@ -14,3 +14,3 @@ # wcagc-mcp

secrets beyond the wcagc API base URL — it translates MCP tool calls into HTTP calls against the
wcagc API and forwards the caller's own API key. All authentication, entitlements, quotas, and
wcagc API and forwards the caller's own bearer. All authentication, entitlements, quotas, and
scan orchestration live in the API; this code is safe to read end to end.

@@ -45,5 +45,4 @@

**Hosted (Streamable HTTP)** — what Claude web/desktop/mobile connectors and ChatGPT use, since
neither runs a local process for you. Add this as a remote MCP connector and paste the same API
key as the bearer token:
**Hosted (Streamable HTTP + managed OAuth)** — what Claude web/desktop/mobile connectors and
ChatGPT use, since neither runs a local process for you. Add this remote MCP connector:

@@ -54,4 +53,8 @@ ```

In ChatGPT, full tool access currently requires a Business, Enterprise, or Edu workspace; on Plus
you can use the read-only Custom GPT Action instead. See
The client discovers `/.well-known/oauth-protected-resource/mcp`, opens the wcagc login/consent
flow, and binds the connection to one Organization. No key copy/paste is required. API-key bearer
authentication remains supported for local stdio and CI.
ChatGPT availability depends on the ChatGPT plan and on whether the client permits action tools;
`scan_url` creates a scan and is not a read-only operation. See
[wcagc.com/integrations/mcp](https://wcagc.com/integrations/mcp).

@@ -85,2 +88,6 @@

| `WCAGC_MCP_INTROSPECT_TTL_SECONDS` | hosted | How long a verified bearer is cached before re-checking with the API (default `60`). |
| `WCAGC_OAUTH_ISSUER` | hosted | Expected OAuth issuer. |
| `WCAGC_OAUTH_JWKS_URL` | hosted | Authorization Server public JWKS URL. |
| `WCAGC_MCP_SERVER_URL` | hosted | Canonical RFC 9728 protected-resource URL. |
| `WCAGC_OAUTH_JWKS_TTL_SECONDS` | hosted | JWKS cache TTL; an unknown `kid` triggers an immediate refetch. |

@@ -87,0 +94,0 @@ ## Development