@wcagc/mcp
Advanced tools
+104
-0
| 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), |
+29
-5
| 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 |
+1
-1
| { | ||
| "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.", |
+13
-6
@@ -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 |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 4 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
54018
11.69%986
15.86%103
7.29%16
33.33%5
25%