@studiometa/forge-mcp 0.4.4 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.js +2 -0
- package/dist/auth.js.map +1 -1
- package/dist/crypto.js +5 -3
- package/dist/crypto.js.map +1 -1
- package/dist/{flags-LFbdErsZ.js → flags-CIh56cQ7.js} +3 -1
- package/dist/{flags-LFbdErsZ.js.map → flags-CIh56cQ7.js.map} +1 -1
- package/dist/{http-BxoFD5eP.js → http-DVoeL5Wa.js} +6 -2
- package/dist/{http-BxoFD5eP.js.map → http-DVoeL5Wa.js.map} +1 -1
- package/dist/http.js +1 -2
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/oauth.js +8 -6
- package/dist/oauth.js.map +1 -1
- package/dist/server.js +6 -5
- package/dist/server.js.map +1 -1
- package/dist/{version-DNuuHvcD.js → version-BdjPN_IX.js} +117 -24
- package/dist/{version-DNuuHvcD.js.map → version-BdjPN_IX.js.map} +1 -1
- package/package.json +3 -3
package/dist/auth.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
//#region src/auth.ts
|
|
1
2
|
/**
|
|
2
3
|
* Try to decode a base64-encoded token.
|
|
3
4
|
* Returns the decoded string if re-encoding produces the original, null otherwise.
|
|
@@ -49,6 +50,7 @@ function parseAuthHeader(authHeader) {
|
|
|
49
50
|
}
|
|
50
51
|
return { apiToken: token };
|
|
51
52
|
}
|
|
53
|
+
//#endregion
|
|
52
54
|
export { parseAuthHeader };
|
|
53
55
|
|
|
54
56
|
//# sourceMappingURL=auth.js.map
|
package/dist/auth.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.js","names":[],"sources":["../src/auth.ts"],"sourcesContent":["/**\n * Authentication utilities for Forge MCP HTTP server\n *\n * Supports two token formats:\n * 1. Raw Forge API token (backwards compatible)\n * 2. Base64-encoded Forge API token (from OAuth flow)\n *\n * Detection heuristic: decode the token from base64, then re-encode.\n * If the re-encoded string matches the original, it's a real base64 token\n * and we use the decoded value. Otherwise, we treat it as a raw token.\n */\n\nexport interface ForgeCredentials {\n apiToken: string;\n organizationSlug?: string;\n}\n\n/**\n * Try to decode a base64-encoded token.\n * Returns the decoded string if re-encoding produces the original, null otherwise.\n *\n * Buffer.from(str, 'base64') never throws — it silently ignores invalid chars.\n * So this function cannot fail, no try/catch needed.\n */\nfunction tryDecodeBase64(token: string): string | null {\n const decoded = Buffer.from(token, \"base64\").toString(\"utf-8\");\n\n // decoded must be non-empty and different from input\n if (!decoded || decoded === token) {\n return null;\n }\n\n // Re-encode and compare — if it roundtrips, it was genuinely base64\n const reEncoded = Buffer.from(decoded).toString(\"base64\");\n if (reEncoded === token) {\n return decoded;\n }\n\n return null;\n}\n\nfunction tryParseCredentials(decoded: string): ForgeCredentials | null {\n try {\n const parsed: unknown = JSON.parse(decoded);\n if (\n typeof parsed === \"object\" &&\n parsed !== null &&\n \"apiToken\" in parsed &&\n typeof (parsed as Record<string, unknown>).apiToken === \"string\"\n ) {\n const obj = parsed as Record<string, unknown>;\n return {\n apiToken: obj.apiToken as string,\n organizationSlug:\n typeof obj.organizationSlug === \"string\" ? obj.organizationSlug : undefined,\n };\n }\n } catch {\n // Not valid JSON, fall through\n }\n return null;\n}\n\n/**\n * Parse Bearer token containing Forge API credentials.\n *\n * Token formats:\n * - Raw Forge API token (e.g., \"Bearer my-api-token\")\n * - Base64-encoded token from OAuth flow (e.g., \"Bearer base64(apiToken)\")\n * - Base64-encoded JSON from OAuth flow (e.g., \"Bearer base64({apiToken, organizationSlug})\")\n *\n * @param authHeader - Authorization header value (e.g., \"Bearer <token>\")\n * @returns Parsed credentials or null if invalid\n */\nexport function parseAuthHeader(authHeader: string | undefined | null): ForgeCredentials | null {\n if (!authHeader) {\n return null;\n }\n\n const match = authHeader.match(/^Bearer\\s+(.+)$/i);\n if (!match) {\n return null;\n }\n\n const token = match[1].trim();\n\n if (!token) {\n return null;\n }\n\n // Try to decode as base64 (OAuth access token)\n const decoded = tryDecodeBase64(token);\n if (decoded) {\n const credentials = tryParseCredentials(decoded);\n if (credentials) {\n return credentials;\n }\n\n return { apiToken: decoded };\n }\n\n // Treat as raw Forge API token (backwards compatible)\n return { apiToken: token };\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"file":"auth.js","names":[],"sources":["../src/auth.ts"],"sourcesContent":["/**\n * Authentication utilities for Forge MCP HTTP server\n *\n * Supports two token formats:\n * 1. Raw Forge API token (backwards compatible)\n * 2. Base64-encoded Forge API token (from OAuth flow)\n *\n * Detection heuristic: decode the token from base64, then re-encode.\n * If the re-encoded string matches the original, it's a real base64 token\n * and we use the decoded value. Otherwise, we treat it as a raw token.\n */\n\nexport interface ForgeCredentials {\n apiToken: string;\n organizationSlug?: string;\n}\n\n/**\n * Try to decode a base64-encoded token.\n * Returns the decoded string if re-encoding produces the original, null otherwise.\n *\n * Buffer.from(str, 'base64') never throws — it silently ignores invalid chars.\n * So this function cannot fail, no try/catch needed.\n */\nfunction tryDecodeBase64(token: string): string | null {\n const decoded = Buffer.from(token, \"base64\").toString(\"utf-8\");\n\n // decoded must be non-empty and different from input\n if (!decoded || decoded === token) {\n return null;\n }\n\n // Re-encode and compare — if it roundtrips, it was genuinely base64\n const reEncoded = Buffer.from(decoded).toString(\"base64\");\n if (reEncoded === token) {\n return decoded;\n }\n\n return null;\n}\n\nfunction tryParseCredentials(decoded: string): ForgeCredentials | null {\n try {\n const parsed: unknown = JSON.parse(decoded);\n if (\n typeof parsed === \"object\" &&\n parsed !== null &&\n \"apiToken\" in parsed &&\n typeof (parsed as Record<string, unknown>).apiToken === \"string\"\n ) {\n const obj = parsed as Record<string, unknown>;\n return {\n apiToken: obj.apiToken as string,\n organizationSlug:\n typeof obj.organizationSlug === \"string\" ? obj.organizationSlug : undefined,\n };\n }\n } catch {\n // Not valid JSON, fall through\n }\n return null;\n}\n\n/**\n * Parse Bearer token containing Forge API credentials.\n *\n * Token formats:\n * - Raw Forge API token (e.g., \"Bearer my-api-token\")\n * - Base64-encoded token from OAuth flow (e.g., \"Bearer base64(apiToken)\")\n * - Base64-encoded JSON from OAuth flow (e.g., \"Bearer base64({apiToken, organizationSlug})\")\n *\n * @param authHeader - Authorization header value (e.g., \"Bearer <token>\")\n * @returns Parsed credentials or null if invalid\n */\nexport function parseAuthHeader(authHeader: string | undefined | null): ForgeCredentials | null {\n if (!authHeader) {\n return null;\n }\n\n const match = authHeader.match(/^Bearer\\s+(.+)$/i);\n if (!match) {\n return null;\n }\n\n const token = match[1].trim();\n\n if (!token) {\n return null;\n }\n\n // Try to decode as base64 (OAuth access token)\n const decoded = tryDecodeBase64(token);\n if (decoded) {\n const credentials = tryParseCredentials(decoded);\n if (credentials) {\n return credentials;\n }\n\n return { apiToken: decoded };\n }\n\n // Treat as raw Forge API token (backwards compatible)\n return { apiToken: token };\n}\n"],"mappings":";;;;;;;;AAwBA,SAAS,gBAAgB,OAA8B;CACrD,MAAM,UAAU,OAAO,KAAK,OAAO,QAAQ,CAAC,CAAC,SAAS,OAAO;CAG7D,IAAI,CAAC,WAAW,YAAY,OAC1B,OAAO;CAKT,IADkB,OAAO,KAAK,OAAO,CAAC,CAAC,SAAS,QAC5C,MAAc,OAChB,OAAO;CAGT,OAAO;AACT;AAEA,SAAS,oBAAoB,SAA0C;CACrE,IAAI;EACF,MAAM,SAAkB,KAAK,MAAM,OAAO;EAC1C,IACE,OAAO,WAAW,YAClB,WAAW,QACX,cAAc,UACd,OAAQ,OAAmC,aAAa,UACxD;GACA,MAAM,MAAM;GACZ,OAAO;IACL,UAAU,IAAI;IACd,kBACE,OAAO,IAAI,qBAAqB,WAAW,IAAI,mBAAmB,KAAA;GACtE;EACF;CACF,QAAQ,CAER;CACA,OAAO;AACT;;;;;;;;;;;;AAaA,SAAgB,gBAAgB,YAAgE;CAC9F,IAAI,CAAC,YACH,OAAO;CAGT,MAAM,QAAQ,WAAW,MAAM,kBAAkB;CACjD,IAAI,CAAC,OACH,OAAO;CAGT,MAAM,QAAQ,MAAM,EAAE,CAAC,KAAK;CAE5B,IAAI,CAAC,OACH,OAAO;CAIT,MAAM,UAAU,gBAAgB,KAAK;CACrC,IAAI,SAAS;EACX,MAAM,cAAc,oBAAoB,OAAO;EAC/C,IAAI,aACF,OAAO;EAGT,OAAO,EAAE,UAAU,QAAQ;CAC7B;CAGA,OAAO,EAAE,UAAU,MAAM;AAC3B"}
|
package/dist/crypto.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
|
2
|
+
//#region src/crypto.ts
|
|
2
3
|
/**
|
|
3
4
|
* Cryptographic utilities for stateless OAuth tokens
|
|
4
5
|
*
|
|
@@ -63,9 +64,9 @@ function decrypt(ciphertext, secret = getSecret()) {
|
|
|
63
64
|
try {
|
|
64
65
|
const combined = Buffer.from(ciphertext, "base64url");
|
|
65
66
|
const salt = combined.subarray(0, SALT_LENGTH);
|
|
66
|
-
const iv = combined.subarray(SALT_LENGTH,
|
|
67
|
-
const authTag = combined.subarray(
|
|
68
|
-
const encrypted = combined.subarray(
|
|
67
|
+
const iv = combined.subarray(SALT_LENGTH, 28);
|
|
68
|
+
const authTag = combined.subarray(28, 44);
|
|
69
|
+
const encrypted = combined.subarray(44);
|
|
69
70
|
const decipher = createDecipheriv(ALGORITHM, deriveKey(secret, salt), iv, { authTagLength: AUTH_TAG_LENGTH });
|
|
70
71
|
decipher.setAuthTag(authTag);
|
|
71
72
|
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
|
|
@@ -106,6 +107,7 @@ function decodeAuthCode(code) {
|
|
|
106
107
|
codeChallengeMethod
|
|
107
108
|
};
|
|
108
109
|
}
|
|
110
|
+
//#endregion
|
|
109
111
|
export { createAuthCode, decodeAuthCode, decrypt, encrypt, getSecret };
|
|
110
112
|
|
|
111
113
|
//# sourceMappingURL=crypto.js.map
|
package/dist/crypto.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"crypto.js","names":[],"sources":["../src/crypto.ts"],"sourcesContent":["/**\n * Cryptographic utilities for stateless OAuth tokens\n *\n * Uses AES-256-GCM for authenticated encryption.\n * The authorization code contains encrypted credentials that can be\n * decrypted without server-side storage.\n */\n\nimport { createCipheriv, createDecipheriv, randomBytes, scryptSync } from \"node:crypto\";\n\nconst ALGORITHM = \"aes-256-gcm\";\nconst IV_LENGTH = 12; // GCM recommended IV length\nconst AUTH_TAG_LENGTH = 16;\nconst SALT_LENGTH = 16;\n\n/**\n * Derive a 256-bit key from a password using scrypt\n */\nfunction deriveKey(password: string, salt: Buffer): Buffer {\n return scryptSync(password, salt, 32);\n}\n\n/**\n * Get the encryption secret from environment or generate a default.\n * In production, OAUTH_SECRET should always be set.\n */\nexport function getSecret(): string {\n const secret = process.env.OAUTH_SECRET;\n if (!secret) {\n console.warn(\n \"WARNING: OAUTH_SECRET not set. Using default secret. Set OAUTH_SECRET in production!\",\n );\n return \"forge-mcp-default-secret-change-me\";\n }\n return secret;\n}\n\n/**\n * Encrypt data using AES-256-GCM\n *\n * Output format: base64url(salt + iv + authTag + ciphertext)\n *\n * @param plaintext - Data to encrypt\n * @param secret - Encryption secret (defaults to OAUTH_SECRET env var)\n * @returns Base64url-encoded encrypted data\n */\nexport function encrypt(plaintext: string, secret: string = getSecret()): string {\n const salt = randomBytes(SALT_LENGTH);\n const key = deriveKey(secret, salt);\n const iv = randomBytes(IV_LENGTH);\n\n const cipher = createCipheriv(ALGORITHM, key, iv);\n const encrypted = Buffer.concat([cipher.update(plaintext, \"utf8\"), cipher.final()]);\n const authTag = cipher.getAuthTag();\n\n // Combine: salt + iv + authTag + ciphertext\n const combined = Buffer.concat([salt, iv, authTag, encrypted]);\n\n return combined.toString(\"base64url\");\n}\n\n/**\n * Decrypt data encrypted with encrypt()\n *\n * @param ciphertext - Base64url-encoded encrypted data\n * @param secret - Encryption secret (defaults to OAUTH_SECRET env var)\n * @returns Decrypted plaintext\n * @throws Error if decryption fails (invalid data or wrong secret)\n */\nexport function decrypt(ciphertext: string, secret: string = getSecret()): string {\n try {\n const combined = Buffer.from(ciphertext, \"base64url\");\n\n // Extract components\n const salt = combined.subarray(0, SALT_LENGTH);\n const iv = combined.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);\n const authTag = combined.subarray(\n SALT_LENGTH + IV_LENGTH,\n SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH,\n );\n const encrypted = combined.subarray(SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);\n\n const key = deriveKey(secret, salt);\n\n const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });\n decipher.setAuthTag(authTag);\n\n const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);\n\n return decrypted.toString(\"utf8\");\n } catch {\n throw new Error(\"Decryption failed: invalid token or secret\");\n }\n}\n\n/**\n * Authorization code payload structure\n */\nexport interface AuthCodePayload {\n apiToken: string;\n organizationSlug?: string;\n codeChallenge?: string;\n codeChallengeMethod?: string;\n}\n\n/**\n * Create an encrypted authorization code containing credentials and PKCE challenge\n *\n * @param credentials - Object with apiToken and optional PKCE params\n * @param expiresInSeconds - Code expiration time (default: 5 minutes)\n * @returns Encrypted authorization code\n */\nexport function createAuthCode(\n credentials: AuthCodePayload,\n expiresInSeconds: number = 300,\n): string {\n const payload = {\n ...credentials,\n exp: Date.now() + expiresInSeconds * 1000,\n };\n return encrypt(JSON.stringify(payload));\n}\n\n/**\n * Decode and validate an authorization code\n *\n * @param code - Encrypted authorization code\n * @returns Decoded payload with credentials and PKCE challenge\n * @throws Error if code is invalid or expired\n */\nexport function decodeAuthCode(code: string): AuthCodePayload {\n const payload = JSON.parse(decrypt(code));\n\n if (payload.exp && Date.now() > payload.exp) {\n throw new Error(\"Authorization code expired\");\n }\n\n const { apiToken, organizationSlug, codeChallenge, codeChallengeMethod } = payload;\n\n if (!apiToken) {\n throw new Error(\"Invalid authorization code: missing credentials\");\n }\n\n return { apiToken, organizationSlug, codeChallenge, codeChallengeMethod };\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"file":"crypto.js","names":[],"sources":["../src/crypto.ts"],"sourcesContent":["/**\n * Cryptographic utilities for stateless OAuth tokens\n *\n * Uses AES-256-GCM for authenticated encryption.\n * The authorization code contains encrypted credentials that can be\n * decrypted without server-side storage.\n */\n\nimport { createCipheriv, createDecipheriv, randomBytes, scryptSync } from \"node:crypto\";\n\nconst ALGORITHM = \"aes-256-gcm\";\nconst IV_LENGTH = 12; // GCM recommended IV length\nconst AUTH_TAG_LENGTH = 16;\nconst SALT_LENGTH = 16;\n\n/**\n * Derive a 256-bit key from a password using scrypt\n */\nfunction deriveKey(password: string, salt: Buffer): Buffer {\n return scryptSync(password, salt, 32);\n}\n\n/**\n * Get the encryption secret from environment or generate a default.\n * In production, OAUTH_SECRET should always be set.\n */\nexport function getSecret(): string {\n const secret = process.env.OAUTH_SECRET;\n if (!secret) {\n console.warn(\n \"WARNING: OAUTH_SECRET not set. Using default secret. Set OAUTH_SECRET in production!\",\n );\n return \"forge-mcp-default-secret-change-me\";\n }\n return secret;\n}\n\n/**\n * Encrypt data using AES-256-GCM\n *\n * Output format: base64url(salt + iv + authTag + ciphertext)\n *\n * @param plaintext - Data to encrypt\n * @param secret - Encryption secret (defaults to OAUTH_SECRET env var)\n * @returns Base64url-encoded encrypted data\n */\nexport function encrypt(plaintext: string, secret: string = getSecret()): string {\n const salt = randomBytes(SALT_LENGTH);\n const key = deriveKey(secret, salt);\n const iv = randomBytes(IV_LENGTH);\n\n const cipher = createCipheriv(ALGORITHM, key, iv);\n const encrypted = Buffer.concat([cipher.update(plaintext, \"utf8\"), cipher.final()]);\n const authTag = cipher.getAuthTag();\n\n // Combine: salt + iv + authTag + ciphertext\n const combined = Buffer.concat([salt, iv, authTag, encrypted]);\n\n return combined.toString(\"base64url\");\n}\n\n/**\n * Decrypt data encrypted with encrypt()\n *\n * @param ciphertext - Base64url-encoded encrypted data\n * @param secret - Encryption secret (defaults to OAUTH_SECRET env var)\n * @returns Decrypted plaintext\n * @throws Error if decryption fails (invalid data or wrong secret)\n */\nexport function decrypt(ciphertext: string, secret: string = getSecret()): string {\n try {\n const combined = Buffer.from(ciphertext, \"base64url\");\n\n // Extract components\n const salt = combined.subarray(0, SALT_LENGTH);\n const iv = combined.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);\n const authTag = combined.subarray(\n SALT_LENGTH + IV_LENGTH,\n SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH,\n );\n const encrypted = combined.subarray(SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);\n\n const key = deriveKey(secret, salt);\n\n const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });\n decipher.setAuthTag(authTag);\n\n const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);\n\n return decrypted.toString(\"utf8\");\n } catch {\n throw new Error(\"Decryption failed: invalid token or secret\");\n }\n}\n\n/**\n * Authorization code payload structure\n */\nexport interface AuthCodePayload {\n apiToken: string;\n organizationSlug?: string;\n codeChallenge?: string;\n codeChallengeMethod?: string;\n}\n\n/**\n * Create an encrypted authorization code containing credentials and PKCE challenge\n *\n * @param credentials - Object with apiToken and optional PKCE params\n * @param expiresInSeconds - Code expiration time (default: 5 minutes)\n * @returns Encrypted authorization code\n */\nexport function createAuthCode(\n credentials: AuthCodePayload,\n expiresInSeconds: number = 300,\n): string {\n const payload = {\n ...credentials,\n exp: Date.now() + expiresInSeconds * 1000,\n };\n return encrypt(JSON.stringify(payload));\n}\n\n/**\n * Decode and validate an authorization code\n *\n * @param code - Encrypted authorization code\n * @returns Decoded payload with credentials and PKCE challenge\n * @throws Error if code is invalid or expired\n */\nexport function decodeAuthCode(code: string): AuthCodePayload {\n const payload = JSON.parse(decrypt(code));\n\n if (payload.exp && Date.now() > payload.exp) {\n throw new Error(\"Authorization code expired\");\n }\n\n const { apiToken, organizationSlug, codeChallenge, codeChallengeMethod } = payload;\n\n if (!apiToken) {\n throw new Error(\"Invalid authorization code: missing credentials\");\n }\n\n return { apiToken, organizationSlug, codeChallenge, codeChallengeMethod };\n}\n"],"mappings":";;;;;;;;;AAUA,IAAM,YAAY;AAClB,IAAM,YAAY;AAClB,IAAM,kBAAkB;AACxB,IAAM,cAAc;;;;AAKpB,SAAS,UAAU,UAAkB,MAAsB;CACzD,OAAO,WAAW,UAAU,MAAM,EAAE;AACtC;;;;;AAMA,SAAgB,YAAoB;CAClC,MAAM,SAAS,QAAQ,IAAI;CAC3B,IAAI,CAAC,QAAQ;EACX,QAAQ,KACN,sFACF;EACA,OAAO;CACT;CACA,OAAO;AACT;;;;;;;;;;AAWA,SAAgB,QAAQ,WAAmB,SAAiB,UAAU,GAAW;CAC/E,MAAM,OAAO,YAAY,WAAW;CACpC,MAAM,MAAM,UAAU,QAAQ,IAAI;CAClC,MAAM,KAAK,YAAY,SAAS;CAEhC,MAAM,SAAS,eAAe,WAAW,KAAK,EAAE;CAChD,MAAM,YAAY,OAAO,OAAO,CAAC,OAAO,OAAO,WAAW,MAAM,GAAG,OAAO,MAAM,CAAC,CAAC;CAClF,MAAM,UAAU,OAAO,WAAW;CAKlC,OAFiB,OAAO,OAAO;EAAC;EAAM;EAAI;EAAS;CAAS,CAErD,CAAA,CAAS,SAAS,WAAW;AACtC;;;;;;;;;AAUA,SAAgB,QAAQ,YAAoB,SAAiB,UAAU,GAAW;CAChF,IAAI;EACF,MAAM,WAAW,OAAO,KAAK,YAAY,WAAW;EAGpD,MAAM,OAAO,SAAS,SAAS,GAAG,WAAW;EAC7C,MAAM,KAAK,SAAS,SAAS,aAAa,EAAuB;EACjE,MAAM,UAAU,SAAS,SACvB,IACA,EACF;EACA,MAAM,YAAY,SAAS,SAAS,EAAyC;EAI7E,MAAM,WAAW,iBAAiB,WAFtB,UAAU,QAAQ,IAEe,GAAK,IAAI,EAAE,eAAe,gBAAgB,CAAC;EACxF,SAAS,WAAW,OAAO;EAI3B,OAFkB,OAAO,OAAO,CAAC,SAAS,OAAO,SAAS,GAAG,SAAS,MAAM,CAAC,CAEtE,CAAA,CAAU,SAAS,MAAM;CAClC,QAAQ;EACN,MAAM,IAAI,MAAM,4CAA4C;CAC9D;AACF;;;;;;;;AAmBA,SAAgB,eACd,aACA,mBAA2B,KACnB;CACR,MAAM,UAAU;EACd,GAAG;EACH,KAAK,KAAK,IAAI,IAAI,mBAAmB;CACvC;CACA,OAAO,QAAQ,KAAK,UAAU,OAAO,CAAC;AACxC;;;;;;;;AASA,SAAgB,eAAe,MAA+B;CAC5D,MAAM,UAAU,KAAK,MAAM,QAAQ,IAAI,CAAC;CAExC,IAAI,QAAQ,OAAO,KAAK,IAAI,IAAI,QAAQ,KACtC,MAAM,IAAI,MAAM,4BAA4B;CAG9C,MAAM,EAAE,UAAU,kBAAkB,eAAe,wBAAwB;CAE3E,IAAI,CAAC,UACH,MAAM,IAAI,MAAM,iDAAiD;CAGnE,OAAO;EAAE;EAAU;EAAkB;EAAe;CAAoB;AAC1E"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
//#region src/flags.ts
|
|
1
2
|
/**
|
|
2
3
|
* CLI flag parsers shared between stdio and HTTP entry points.
|
|
3
4
|
*
|
|
@@ -18,6 +19,7 @@
|
|
|
18
19
|
function parseReadOnlyFlag() {
|
|
19
20
|
return process.argv.includes("--read-only") || process.env.FORGE_READ_ONLY === "true";
|
|
20
21
|
}
|
|
22
|
+
//#endregion
|
|
21
23
|
export { parseReadOnlyFlag as t };
|
|
22
24
|
|
|
23
|
-
//# sourceMappingURL=flags-
|
|
25
|
+
//# sourceMappingURL=flags-CIh56cQ7.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"flags-
|
|
1
|
+
{"version":3,"file":"flags-CIh56cQ7.js","names":[],"sources":["../src/flags.ts"],"sourcesContent":["/**\n * CLI flag parsers shared between stdio and HTTP entry points.\n *\n * Extracted to a separate module so that both `index.ts` (stdio) and\n * `server.ts` (HTTP) can import it without creating a shared dependency\n * that triggers Vite code-splitting — which would break the `isMainModule`\n * guard in `index.ts`.\n *\n * See: https://github.com/studiometa/forge-tools/issues/63\n */\n\n/**\n * Parse read-only flag from process.argv and environment.\n *\n * Supports:\n * - `--read-only` CLI flag\n * - `FORGE_READ_ONLY=true` environment variable\n */\nexport function parseReadOnlyFlag(): boolean {\n return process.argv.includes(\"--read-only\") || process.env.FORGE_READ_ONLY === \"true\";\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAkBA,SAAgB,oBAA6B;CAC3C,OAAO,QAAQ,KAAK,SAAS,aAAa,KAAK,QAAQ,IAAI,oBAAoB;AACjF"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as INSTRUCTIONS, i as getTools, n as executeToolWithCredentials, t as VERSION } from "./version-
|
|
1
|
+
import { a as INSTRUCTIONS, i as getTools, n as executeToolWithCredentials, t as VERSION } from "./version-BdjPN_IX.js";
|
|
2
2
|
import { parseAuthHeader } from "./auth.js";
|
|
3
3
|
import { authorizeGetHandler, authorizePostHandler, oauthMetadataHandler, protectedResourceHandler, registerHandler, tokenHandler } from "./oauth.js";
|
|
4
4
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
@@ -7,6 +7,7 @@ import { getOrganizationSlug } from "@studiometa/forge-api";
|
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
8
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
9
9
|
import { H3, defineEventHandler } from "h3";
|
|
10
|
+
//#region src/sessions.ts
|
|
10
11
|
var DEFAULT_TTL = 1800 * 1e3;
|
|
11
12
|
var DEFAULT_SWEEP_INTERVAL = 60 * 1e3;
|
|
12
13
|
var SessionManager = class {
|
|
@@ -94,6 +95,8 @@ var SessionManager = class {
|
|
|
94
95
|
this.sessions.clear();
|
|
95
96
|
}
|
|
96
97
|
};
|
|
98
|
+
//#endregion
|
|
99
|
+
//#region src/http.ts
|
|
97
100
|
/**
|
|
98
101
|
* Streamable HTTP transport for Forge MCP Server
|
|
99
102
|
*
|
|
@@ -296,6 +299,7 @@ function createHealthApp() {
|
|
|
296
299
|
app.post("/token", tokenHandler);
|
|
297
300
|
return app;
|
|
298
301
|
}
|
|
302
|
+
//#endregion
|
|
299
303
|
export { SessionManager as a, handleMcpRequest as i, createMcpRequestHandler as n, createMcpServer as r, createHealthApp as t };
|
|
300
304
|
|
|
301
|
-
//# sourceMappingURL=http-
|
|
305
|
+
//# sourceMappingURL=http-DVoeL5Wa.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http-BxoFD5eP.js","names":[],"sources":["../src/sessions.ts","../src/http.ts"],"sourcesContent":["/* eslint-disable typescript-eslint/no-deprecated -- Using low-level Server type for session management */\n/**\n * Session manager for multi-tenant Streamable HTTP transport.\n *\n * Each MCP client session gets its own transport + server pair.\n * Sessions are identified by UUID and tracked in a Map.\n *\n * Supports automatic TTL-based cleanup of idle sessions to prevent\n * memory leaks from abandoned clients.\n */\n\n// Using low-level Server type for advanced transport handling\n// eslint-disable-next-line typescript-eslint/no-deprecated\nimport type { Server } from \"@modelcontextprotocol/sdk/server/index.js\";\nimport type { StreamableHTTPServerTransport } from \"@modelcontextprotocol/sdk/server/streamableHttp.js\";\n\n/**\n * A managed session: transport + MCP server pair.\n */\nexport interface ManagedSession {\n transport: StreamableHTTPServerTransport;\n server: Server;\n createdAt: number;\n lastActiveAt: number;\n}\n\nexport interface SessionManagerOptions {\n /**\n * Maximum idle time in milliseconds before a session is reaped.\n * Default: 30 minutes. Set to 0 to disable automatic cleanup.\n */\n ttl?: number;\n\n /**\n * How often to check for expired sessions, in milliseconds.\n * Default: 60 seconds.\n */\n sweepInterval?: number;\n}\n\nconst DEFAULT_TTL = 30 * 60 * 1000; // 30 minutes\nconst DEFAULT_SWEEP_INTERVAL = 60 * 1000; // 60 seconds\n\nexport class SessionManager {\n private sessions = new Map<string, ManagedSession>();\n // eslint-disable-next-line typescript-eslint/no-redundant-type-constituents -- NodeJS.Timeout resolves correctly at runtime\n private sweepTimer: ReturnType<typeof setInterval> | undefined;\n private readonly ttl: number;\n\n constructor(options?: SessionManagerOptions) {\n this.ttl = options?.ttl ?? DEFAULT_TTL;\n\n if (this.ttl > 0) {\n const interval = options?.sweepInterval ?? DEFAULT_SWEEP_INTERVAL;\n this.sweepTimer = setInterval(() => {\n this.sweep();\n }, interval);\n // Don't keep the process alive just for the sweep timer\n this.sweepTimer.unref();\n }\n }\n\n /**\n * Register a session after its ID has been assigned by the transport.\n */\n register(transport: StreamableHTTPServerTransport, server: Server): void {\n const sessionId = transport.sessionId;\n if (sessionId) {\n const now = Date.now();\n this.sessions.set(sessionId, {\n transport,\n server,\n createdAt: now,\n lastActiveAt: now,\n });\n }\n }\n\n /**\n * Look up a session by its ID and refresh its activity timestamp.\n */\n get(sessionId: string): ManagedSession | undefined {\n const session = this.sessions.get(sessionId);\n if (session) {\n session.lastActiveAt = Date.now();\n }\n return session;\n }\n\n /**\n * Remove a session and close its transport + server.\n */\n async remove(sessionId: string): Promise<void> {\n const session = this.sessions.get(sessionId);\n if (session) {\n this.sessions.delete(sessionId);\n await session.transport.close();\n await session.server.close();\n }\n }\n\n /**\n * Get the number of active sessions.\n */\n get size(): number {\n return this.sessions.size;\n }\n\n /**\n * Sweep expired sessions. Called automatically by the sweep timer.\n * Returns the number of sessions reaped.\n */\n sweep(): number {\n if (this.ttl <= 0) return 0;\n\n const now = Date.now();\n const expired: string[] = [];\n\n for (const [id, session] of this.sessions) {\n if (now - session.lastActiveAt > this.ttl) {\n expired.push(id);\n }\n }\n\n for (const id of expired) {\n // Fire-and-forget cleanup — don't block the sweep\n /* v8 ignore start */\n this.remove(id).catch(() => {});\n /* v8 ignore stop */\n }\n\n return expired.length;\n }\n\n /**\n * Close all sessions, stop the sweep timer, and clean up.\n */\n async closeAll(): Promise<void> {\n if (this.sweepTimer) {\n clearInterval(this.sweepTimer);\n this.sweepTimer = undefined;\n }\n\n const promises: Promise<void>[] = [];\n for (const [, session] of this.sessions) {\n promises.push(session.transport.close());\n promises.push(session.server.close());\n }\n await Promise.all(promises);\n this.sessions.clear();\n }\n}\n","/* eslint-disable typescript-eslint/no-deprecated -- Using low-level Server for StreamableHTTPServerTransport */\n/**\n * Streamable HTTP transport for Forge MCP Server\n *\n * Implements the official MCP Streamable HTTP transport specification (2025-03-26)\n * using the SDK's StreamableHTTPServerTransport.\n *\n * Architecture:\n * - Stateful mode with per-session transport+server pairs (multi-tenant)\n * - Auth via Bearer token → authInfo.token → handler extra.authInfo\n * - Session manager (injected) maps session IDs to transport+server instances\n * - Health/status endpoints handled by h3, MCP endpoint by the SDK transport\n */\n\nimport { randomUUID } from \"node:crypto\";\nimport type { IncomingMessage, ServerResponse } from \"node:http\";\n\n// Using low-level Server for advanced transport handling (StreamableHTTPServerTransport)\n// eslint-disable-next-line typescript-eslint/no-deprecated\nimport { Server } from \"@modelcontextprotocol/sdk/server/index.js\";\nimport { StreamableHTTPServerTransport } from \"@modelcontextprotocol/sdk/server/streamableHttp.js\";\nimport {\n CallToolRequestSchema,\n ListToolsRequestSchema,\n type CallToolResult,\n} from \"@modelcontextprotocol/sdk/types.js\";\nimport { getOrganizationSlug } from \"@studiometa/forge-api\";\nimport { H3, defineEventHandler } from \"h3\";\n\nimport { parseAuthHeader } from \"./auth.ts\";\nimport { executeToolWithCredentials } from \"./handlers/index.ts\";\nimport { INSTRUCTIONS } from \"./instructions.ts\";\nimport {\n oauthMetadataHandler,\n protectedResourceHandler,\n registerHandler,\n authorizeGetHandler,\n authorizePostHandler,\n tokenHandler,\n} from \"./oauth.ts\";\nimport { SessionManager } from \"./sessions.ts\";\nimport { getTools } from \"./tools.ts\";\nimport { VERSION } from \"./version.ts\";\n\nexport { SessionManager } from \"./sessions.ts\";\n\n/**\n * Options for the HTTP MCP server.\n */\nexport interface HttpServerOptions {\n /** When true, forge_write tool is not registered and write operations are rejected. */\n readOnly?: boolean;\n}\n\n/**\n * Create a configured MCP Server instance for HTTP transport.\n *\n * Unlike stdio, HTTP mode does NOT include forge_configure/forge_get_config\n * because API tokens come from the Authorization header per-request.\n * Organization slug still falls back to FORGE_ORG/config when omitted.\n */\nexport function createMcpServer(options?: HttpServerOptions): Server {\n const readOnly = options?.readOnly ?? false;\n const tools = getTools({ readOnly });\n\n const server = new Server(\n {\n name: \"forge-mcp\",\n version: VERSION,\n },\n {\n capabilities: {\n tools: {},\n },\n instructions: INSTRUCTIONS,\n },\n );\n\n server.setRequestHandler(ListToolsRequestSchema, async () => {\n return { tools };\n });\n\n server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {\n const { name, arguments: args } = request.params;\n const authInfo = extra.authInfo as { token?: string; organizationSlug?: string } | undefined;\n const token = authInfo?.token;\n const organizationSlugFromAuth =\n typeof authInfo?.organizationSlug === \"string\" ? authInfo.organizationSlug : undefined;\n\n /* v8 ignore start */\n if (!token) {\n return {\n content: [\n {\n type: \"text\" as const,\n text: \"Error: Authentication required. No token found in request.\",\n },\n ],\n structuredContent: {\n success: false,\n error: \"Authentication required. No token found in request.\",\n },\n isError: true,\n };\n }\n /* v8 ignore stop */\n\n // Reject write operations in read-only mode\n if (readOnly && name === \"forge_write\") {\n return {\n content: [\n {\n type: \"text\" as const,\n text: \"Error: Server is running in read-only mode. Write operations are disabled.\",\n },\n ],\n structuredContent: {\n success: false,\n error: \"Server is running in read-only mode. Write operations are disabled.\",\n },\n isError: true,\n };\n }\n\n try {\n // Organization slug can come from:\n // 1. Tool args (per-request override, highest priority)\n // 2. Auth token (configured during OAuth flow)\n // 3. FORGE_ORG / config fallback\n /* v8 ignore next 2 -- defensive type guard */\n const orgSlugFromArgs =\n typeof args?.organizationSlug === \"string\" ? args.organizationSlug : undefined;\n const result = await executeToolWithCredentials(name, /* v8 ignore next */ args ?? {}, {\n apiToken: token,\n organizationSlug:\n orgSlugFromArgs ?? organizationSlugFromAuth ?? getOrganizationSlug() ?? undefined,\n });\n return result as CallToolResult;\n } catch (error) {\n /* v8 ignore start */\n const message = error instanceof Error ? error.message : String(error);\n /* v8 ignore stop */\n return {\n content: [{ type: \"text\" as const, text: `Error: ${message}` }],\n structuredContent: { success: false, error: message },\n isError: true,\n };\n }\n });\n\n return server;\n}\n\n/**\n * Handle an MCP request using the Streamable HTTP transport.\n *\n * Routes requests based on whether they have a session ID:\n * - No session ID + initialize request → create new session\n * - Has session ID → route to existing session's transport\n *\n * @param req - Node.js IncomingMessage\n * @param res - Node.js ServerResponse\n * @param sessions - Session manager instance (injected)\n * @param options - Server options (read-only mode, etc.)\n */\nexport async function handleMcpRequest(\n req: IncomingMessage,\n res: ServerResponse,\n sessions: SessionManager,\n options?: HttpServerOptions,\n): Promise<void> {\n // Extract and validate auth\n const authHeader = req.headers.authorization;\n const credentials = parseAuthHeader(authHeader);\n\n if (!credentials) {\n // Build resource_metadata URL for the WWW-Authenticate header (RFC 9728)\n const host = req.headers.host || \"localhost:3000\";\n const proto = req.headers[\"x-forwarded-proto\"];\n const protocol = (typeof proto === \"string\" ? proto : undefined) || \"http\";\n const resourceMetadataUrl = `${protocol}://${host}/.well-known/oauth-protected-resource`;\n\n res.writeHead(401, {\n \"Content-Type\": \"application/json\",\n \"WWW-Authenticate\": `Bearer resource_metadata=\"${resourceMetadataUrl}\"`,\n });\n res.end(\n JSON.stringify({\n jsonrpc: \"2.0\",\n error: {\n code: -32001,\n message: \"Authentication required. Provide a Bearer token with your Forge API token.\",\n },\n id: null,\n }),\n );\n return;\n }\n\n // Inject auth info for the SDK transport (MCP SDK expects auth on request)\n // Include organizationSlug in extra for downstream handlers\n Object.assign(req, {\n auth: {\n token: credentials.apiToken,\n organizationSlug: credentials.organizationSlug,\n clientId: \"forge-http-client\",\n scopes: [],\n extra: {\n organizationSlug: credentials.organizationSlug,\n },\n },\n });\n\n const sessionHeader = req.headers[\"mcp-session-id\"];\n const sessionId = typeof sessionHeader === \"string\" ? sessionHeader : undefined;\n\n if (sessionId) {\n // Existing session — route to its transport\n const session = sessions.get(sessionId);\n if (!session) {\n res.writeHead(404, { \"Content-Type\": \"application/json\" });\n res.end(\n JSON.stringify({\n jsonrpc: \"2.0\",\n error: {\n code: -32000,\n message: \"Session not found. The session may have expired or been terminated.\",\n },\n id: null,\n }),\n );\n return;\n }\n\n await session.transport.handleRequest(req, res);\n return;\n }\n\n // No session ID — this should be an initialize request.\n // Create a new transport + server pair.\n const transport = new StreamableHTTPServerTransport({\n sessionIdGenerator: () => randomUUID(),\n });\n\n const server = createMcpServer(options);\n await server.connect(transport);\n\n // Set up cleanup on close\n // eslint-disable-next-line unicorn/prefer-add-event-listener -- MCP SDK uses property assignment\n transport.onclose = () => {\n const sid = transport.sessionId;\n /* v8 ignore start */\n if (sid) {\n sessions.remove(sid).catch(() => {\n // Ignore cleanup errors\n });\n }\n /* v8 ignore stop */\n };\n\n // Handle the request (this will set transport.sessionId during initialize)\n await transport.handleRequest(req, res);\n\n // After handling, register the session if the transport got a session ID\n /* v8 ignore start */\n if (transport.sessionId) {\n sessions.register(transport, server);\n } else {\n // No session was created (e.g., invalid request) — clean up\n await transport.close();\n await server.close();\n }\n /* v8 ignore stop */\n}\n\n/**\n * Create a request handler bound to a SessionManager instance.\n * Convenience factory for server.ts.\n */\nexport function createMcpRequestHandler(\n sessions: SessionManager,\n options?: HttpServerOptions,\n): (req: IncomingMessage, res: ServerResponse) => Promise<void> {\n /* v8 ignore start */\n return (req, res) => handleMcpRequest(req, res, sessions, options);\n /* v8 ignore stop */\n}\n\n/**\n * Create h3 app for health check, service info, and OAuth endpoints.\n * The MCP endpoint is handled separately by handleMcpRequest.\n */\nexport function createHealthApp(): H3 {\n const app = new H3();\n\n // Service info & health\n app.get(\n \"/\",\n defineEventHandler(() => {\n return { status: \"ok\", service: \"forge-mcp\", version: VERSION };\n }),\n );\n\n app.get(\n \"/health\",\n defineEventHandler(() => {\n return { status: \"ok\" };\n }),\n );\n\n // OAuth 2.1 endpoints\n app.get(\"/.well-known/oauth-authorization-server\", oauthMetadataHandler);\n app.get(\"/.well-known/oauth-protected-resource\", protectedResourceHandler);\n app.post(\"/register\", registerHandler);\n app.get(\"/authorize\", authorizeGetHandler);\n app.post(\"/authorize\", authorizePostHandler);\n app.post(\"/token\", tokenHandler);\n\n return app;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"http-DVoeL5Wa.js","names":[],"sources":["../src/sessions.ts","../src/http.ts"],"sourcesContent":["/* eslint-disable typescript-eslint/no-deprecated -- Using low-level Server type for session management */\n/**\n * Session manager for multi-tenant Streamable HTTP transport.\n *\n * Each MCP client session gets its own transport + server pair.\n * Sessions are identified by UUID and tracked in a Map.\n *\n * Supports automatic TTL-based cleanup of idle sessions to prevent\n * memory leaks from abandoned clients.\n */\n\n// Using low-level Server type for advanced transport handling\n// eslint-disable-next-line typescript-eslint/no-deprecated\nimport type { Server } from \"@modelcontextprotocol/sdk/server/index.js\";\nimport type { StreamableHTTPServerTransport } from \"@modelcontextprotocol/sdk/server/streamableHttp.js\";\n\n/**\n * A managed session: transport + MCP server pair.\n */\nexport interface ManagedSession {\n transport: StreamableHTTPServerTransport;\n server: Server;\n createdAt: number;\n lastActiveAt: number;\n}\n\nexport interface SessionManagerOptions {\n /**\n * Maximum idle time in milliseconds before a session is reaped.\n * Default: 30 minutes. Set to 0 to disable automatic cleanup.\n */\n ttl?: number;\n\n /**\n * How often to check for expired sessions, in milliseconds.\n * Default: 60 seconds.\n */\n sweepInterval?: number;\n}\n\nconst DEFAULT_TTL = 30 * 60 * 1000; // 30 minutes\nconst DEFAULT_SWEEP_INTERVAL = 60 * 1000; // 60 seconds\n\nexport class SessionManager {\n private sessions = new Map<string, ManagedSession>();\n // eslint-disable-next-line typescript-eslint/no-redundant-type-constituents -- NodeJS.Timeout resolves correctly at runtime\n private sweepTimer: ReturnType<typeof setInterval> | undefined;\n private readonly ttl: number;\n\n constructor(options?: SessionManagerOptions) {\n this.ttl = options?.ttl ?? DEFAULT_TTL;\n\n if (this.ttl > 0) {\n const interval = options?.sweepInterval ?? DEFAULT_SWEEP_INTERVAL;\n this.sweepTimer = setInterval(() => {\n this.sweep();\n }, interval);\n // Don't keep the process alive just for the sweep timer\n this.sweepTimer.unref();\n }\n }\n\n /**\n * Register a session after its ID has been assigned by the transport.\n */\n register(transport: StreamableHTTPServerTransport, server: Server): void {\n const sessionId = transport.sessionId;\n if (sessionId) {\n const now = Date.now();\n this.sessions.set(sessionId, {\n transport,\n server,\n createdAt: now,\n lastActiveAt: now,\n });\n }\n }\n\n /**\n * Look up a session by its ID and refresh its activity timestamp.\n */\n get(sessionId: string): ManagedSession | undefined {\n const session = this.sessions.get(sessionId);\n if (session) {\n session.lastActiveAt = Date.now();\n }\n return session;\n }\n\n /**\n * Remove a session and close its transport + server.\n */\n async remove(sessionId: string): Promise<void> {\n const session = this.sessions.get(sessionId);\n if (session) {\n this.sessions.delete(sessionId);\n await session.transport.close();\n await session.server.close();\n }\n }\n\n /**\n * Get the number of active sessions.\n */\n get size(): number {\n return this.sessions.size;\n }\n\n /**\n * Sweep expired sessions. Called automatically by the sweep timer.\n * Returns the number of sessions reaped.\n */\n sweep(): number {\n if (this.ttl <= 0) return 0;\n\n const now = Date.now();\n const expired: string[] = [];\n\n for (const [id, session] of this.sessions) {\n if (now - session.lastActiveAt > this.ttl) {\n expired.push(id);\n }\n }\n\n for (const id of expired) {\n // Fire-and-forget cleanup — don't block the sweep\n /* v8 ignore start */\n this.remove(id).catch(() => {});\n /* v8 ignore stop */\n }\n\n return expired.length;\n }\n\n /**\n * Close all sessions, stop the sweep timer, and clean up.\n */\n async closeAll(): Promise<void> {\n if (this.sweepTimer) {\n clearInterval(this.sweepTimer);\n this.sweepTimer = undefined;\n }\n\n const promises: Promise<void>[] = [];\n for (const [, session] of this.sessions) {\n promises.push(session.transport.close());\n promises.push(session.server.close());\n }\n await Promise.all(promises);\n this.sessions.clear();\n }\n}\n","/* eslint-disable typescript-eslint/no-deprecated -- Using low-level Server for StreamableHTTPServerTransport */\n/**\n * Streamable HTTP transport for Forge MCP Server\n *\n * Implements the official MCP Streamable HTTP transport specification (2025-03-26)\n * using the SDK's StreamableHTTPServerTransport.\n *\n * Architecture:\n * - Stateful mode with per-session transport+server pairs (multi-tenant)\n * - Auth via Bearer token → authInfo.token → handler extra.authInfo\n * - Session manager (injected) maps session IDs to transport+server instances\n * - Health/status endpoints handled by h3, MCP endpoint by the SDK transport\n */\n\nimport { randomUUID } from \"node:crypto\";\nimport type { IncomingMessage, ServerResponse } from \"node:http\";\n\n// Using low-level Server for advanced transport handling (StreamableHTTPServerTransport)\n// eslint-disable-next-line typescript-eslint/no-deprecated\nimport { Server } from \"@modelcontextprotocol/sdk/server/index.js\";\nimport { StreamableHTTPServerTransport } from \"@modelcontextprotocol/sdk/server/streamableHttp.js\";\nimport {\n CallToolRequestSchema,\n ListToolsRequestSchema,\n type CallToolResult,\n} from \"@modelcontextprotocol/sdk/types.js\";\nimport { getOrganizationSlug } from \"@studiometa/forge-api\";\nimport { H3, defineEventHandler } from \"h3\";\n\nimport { parseAuthHeader } from \"./auth.ts\";\nimport { executeToolWithCredentials } from \"./handlers/index.ts\";\nimport { INSTRUCTIONS } from \"./instructions.ts\";\nimport {\n oauthMetadataHandler,\n protectedResourceHandler,\n registerHandler,\n authorizeGetHandler,\n authorizePostHandler,\n tokenHandler,\n} from \"./oauth.ts\";\nimport { SessionManager } from \"./sessions.ts\";\nimport { getTools } from \"./tools.ts\";\nimport { VERSION } from \"./version.ts\";\n\nexport { SessionManager } from \"./sessions.ts\";\n\n/**\n * Options for the HTTP MCP server.\n */\nexport interface HttpServerOptions {\n /** When true, forge_write tool is not registered and write operations are rejected. */\n readOnly?: boolean;\n}\n\n/**\n * Create a configured MCP Server instance for HTTP transport.\n *\n * Unlike stdio, HTTP mode does NOT include forge_configure/forge_get_config\n * because API tokens come from the Authorization header per-request.\n * Organization slug still falls back to FORGE_ORG/config when omitted.\n */\nexport function createMcpServer(options?: HttpServerOptions): Server {\n const readOnly = options?.readOnly ?? false;\n const tools = getTools({ readOnly });\n\n const server = new Server(\n {\n name: \"forge-mcp\",\n version: VERSION,\n },\n {\n capabilities: {\n tools: {},\n },\n instructions: INSTRUCTIONS,\n },\n );\n\n server.setRequestHandler(ListToolsRequestSchema, async () => {\n return { tools };\n });\n\n server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {\n const { name, arguments: args } = request.params;\n const authInfo = extra.authInfo as { token?: string; organizationSlug?: string } | undefined;\n const token = authInfo?.token;\n const organizationSlugFromAuth =\n typeof authInfo?.organizationSlug === \"string\" ? authInfo.organizationSlug : undefined;\n\n /* v8 ignore start */\n if (!token) {\n return {\n content: [\n {\n type: \"text\" as const,\n text: \"Error: Authentication required. No token found in request.\",\n },\n ],\n structuredContent: {\n success: false,\n error: \"Authentication required. No token found in request.\",\n },\n isError: true,\n };\n }\n /* v8 ignore stop */\n\n // Reject write operations in read-only mode\n if (readOnly && name === \"forge_write\") {\n return {\n content: [\n {\n type: \"text\" as const,\n text: \"Error: Server is running in read-only mode. Write operations are disabled.\",\n },\n ],\n structuredContent: {\n success: false,\n error: \"Server is running in read-only mode. Write operations are disabled.\",\n },\n isError: true,\n };\n }\n\n try {\n // Organization slug can come from:\n // 1. Tool args (per-request override, highest priority)\n // 2. Auth token (configured during OAuth flow)\n // 3. FORGE_ORG / config fallback\n /* v8 ignore next 2 -- defensive type guard */\n const orgSlugFromArgs =\n typeof args?.organizationSlug === \"string\" ? args.organizationSlug : undefined;\n const result = await executeToolWithCredentials(name, /* v8 ignore next */ args ?? {}, {\n apiToken: token,\n organizationSlug:\n orgSlugFromArgs ?? organizationSlugFromAuth ?? getOrganizationSlug() ?? undefined,\n });\n return result as CallToolResult;\n } catch (error) {\n /* v8 ignore start */\n const message = error instanceof Error ? error.message : String(error);\n /* v8 ignore stop */\n return {\n content: [{ type: \"text\" as const, text: `Error: ${message}` }],\n structuredContent: { success: false, error: message },\n isError: true,\n };\n }\n });\n\n return server;\n}\n\n/**\n * Handle an MCP request using the Streamable HTTP transport.\n *\n * Routes requests based on whether they have a session ID:\n * - No session ID + initialize request → create new session\n * - Has session ID → route to existing session's transport\n *\n * @param req - Node.js IncomingMessage\n * @param res - Node.js ServerResponse\n * @param sessions - Session manager instance (injected)\n * @param options - Server options (read-only mode, etc.)\n */\nexport async function handleMcpRequest(\n req: IncomingMessage,\n res: ServerResponse,\n sessions: SessionManager,\n options?: HttpServerOptions,\n): Promise<void> {\n // Extract and validate auth\n const authHeader = req.headers.authorization;\n const credentials = parseAuthHeader(authHeader);\n\n if (!credentials) {\n // Build resource_metadata URL for the WWW-Authenticate header (RFC 9728)\n const host = req.headers.host || \"localhost:3000\";\n const proto = req.headers[\"x-forwarded-proto\"];\n const protocol = (typeof proto === \"string\" ? proto : undefined) || \"http\";\n const resourceMetadataUrl = `${protocol}://${host}/.well-known/oauth-protected-resource`;\n\n res.writeHead(401, {\n \"Content-Type\": \"application/json\",\n \"WWW-Authenticate\": `Bearer resource_metadata=\"${resourceMetadataUrl}\"`,\n });\n res.end(\n JSON.stringify({\n jsonrpc: \"2.0\",\n error: {\n code: -32001,\n message: \"Authentication required. Provide a Bearer token with your Forge API token.\",\n },\n id: null,\n }),\n );\n return;\n }\n\n // Inject auth info for the SDK transport (MCP SDK expects auth on request)\n // Include organizationSlug in extra for downstream handlers\n Object.assign(req, {\n auth: {\n token: credentials.apiToken,\n organizationSlug: credentials.organizationSlug,\n clientId: \"forge-http-client\",\n scopes: [],\n extra: {\n organizationSlug: credentials.organizationSlug,\n },\n },\n });\n\n const sessionHeader = req.headers[\"mcp-session-id\"];\n const sessionId = typeof sessionHeader === \"string\" ? sessionHeader : undefined;\n\n if (sessionId) {\n // Existing session — route to its transport\n const session = sessions.get(sessionId);\n if (!session) {\n res.writeHead(404, { \"Content-Type\": \"application/json\" });\n res.end(\n JSON.stringify({\n jsonrpc: \"2.0\",\n error: {\n code: -32000,\n message: \"Session not found. The session may have expired or been terminated.\",\n },\n id: null,\n }),\n );\n return;\n }\n\n await session.transport.handleRequest(req, res);\n return;\n }\n\n // No session ID — this should be an initialize request.\n // Create a new transport + server pair.\n const transport = new StreamableHTTPServerTransport({\n sessionIdGenerator: () => randomUUID(),\n });\n\n const server = createMcpServer(options);\n await server.connect(transport);\n\n // Set up cleanup on close\n // eslint-disable-next-line unicorn/prefer-add-event-listener -- MCP SDK uses property assignment\n transport.onclose = () => {\n const sid = transport.sessionId;\n /* v8 ignore start */\n if (sid) {\n sessions.remove(sid).catch(() => {\n // Ignore cleanup errors\n });\n }\n /* v8 ignore stop */\n };\n\n // Handle the request (this will set transport.sessionId during initialize)\n await transport.handleRequest(req, res);\n\n // After handling, register the session if the transport got a session ID\n /* v8 ignore start */\n if (transport.sessionId) {\n sessions.register(transport, server);\n } else {\n // No session was created (e.g., invalid request) — clean up\n await transport.close();\n await server.close();\n }\n /* v8 ignore stop */\n}\n\n/**\n * Create a request handler bound to a SessionManager instance.\n * Convenience factory for server.ts.\n */\nexport function createMcpRequestHandler(\n sessions: SessionManager,\n options?: HttpServerOptions,\n): (req: IncomingMessage, res: ServerResponse) => Promise<void> {\n /* v8 ignore start */\n return (req, res) => handleMcpRequest(req, res, sessions, options);\n /* v8 ignore stop */\n}\n\n/**\n * Create h3 app for health check, service info, and OAuth endpoints.\n * The MCP endpoint is handled separately by handleMcpRequest.\n */\nexport function createHealthApp(): H3 {\n const app = new H3();\n\n // Service info & health\n app.get(\n \"/\",\n defineEventHandler(() => {\n return { status: \"ok\", service: \"forge-mcp\", version: VERSION };\n }),\n );\n\n app.get(\n \"/health\",\n defineEventHandler(() => {\n return { status: \"ok\" };\n }),\n );\n\n // OAuth 2.1 endpoints\n app.get(\"/.well-known/oauth-authorization-server\", oauthMetadataHandler);\n app.get(\"/.well-known/oauth-protected-resource\", protectedResourceHandler);\n app.post(\"/register\", registerHandler);\n app.get(\"/authorize\", authorizeGetHandler);\n app.post(\"/authorize\", authorizePostHandler);\n app.post(\"/token\", tokenHandler);\n\n return app;\n}\n"],"mappings":"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"}
|
package/dist/http.js
CHANGED
|
@@ -1,3 +1,2 @@
|
|
|
1
|
-
import "./
|
|
2
|
-
import { a as SessionManager, i as handleMcpRequest, n as createMcpRequestHandler, r as createMcpServer, t as createHealthApp } from "./http-BxoFD5eP.js";
|
|
1
|
+
import { a as SessionManager, i as handleMcpRequest, n as createMcpRequestHandler, r as createMcpServer, t as createHealthApp } from "./http-DVoeL5Wa.js";
|
|
3
2
|
export { SessionManager, createHealthApp, createMcpRequestHandler, createMcpServer, handleMcpRequest };
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { t as parseReadOnlyFlag } from "./flags-
|
|
3
|
-
import { a as INSTRUCTIONS, i as getTools, n as executeToolWithCredentials, r as STDIO_ONLY_TOOLS, t as VERSION } from "./version-
|
|
2
|
+
import { t as parseReadOnlyFlag } from "./flags-CIh56cQ7.js";
|
|
3
|
+
import { a as INSTRUCTIONS, i as getTools, n as executeToolWithCredentials, r as STDIO_ONLY_TOOLS, t as VERSION } from "./version-BdjPN_IX.js";
|
|
4
4
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
6
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
7
7
|
import { getOrganizationSlug, getToken, setOrganizationSlug, setToken } from "@studiometa/forge-api";
|
|
8
|
+
//#region src/stdio.ts
|
|
8
9
|
/**
|
|
9
10
|
* Get all available tools (including stdio-only configuration tools).
|
|
10
11
|
*
|
|
@@ -121,6 +122,8 @@ async function handleToolCall(name, args, options) {
|
|
|
121
122
|
isError: true
|
|
122
123
|
};
|
|
123
124
|
}
|
|
125
|
+
//#endregion
|
|
126
|
+
//#region src/index.ts
|
|
124
127
|
/**
|
|
125
128
|
* Forge MCP Server — Stdio Transport
|
|
126
129
|
*
|
|
@@ -193,6 +196,7 @@ if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith
|
|
|
193
196
|
console.error("Fatal error:", error);
|
|
194
197
|
process.exit(1);
|
|
195
198
|
});
|
|
199
|
+
//#endregion
|
|
196
200
|
export { createStdioServer, parseReadOnlyFlag, startStdioServer };
|
|
197
201
|
|
|
198
202
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/stdio.ts","../src/index.ts"],"sourcesContent":["import {\n getToken,\n getOrganizationSlug,\n setToken,\n setOrganizationSlug,\n} from \"@studiometa/forge-api\";\n\nimport type { ToolResult } from \"./handlers/types.ts\";\n\nimport { executeToolWithCredentials } from \"./handlers/index.ts\";\nimport type { Tool } from \"@modelcontextprotocol/sdk/types.js\";\nimport { getTools, STDIO_ONLY_TOOLS } from \"./tools.ts\";\nimport type { GetToolsOptions } from \"./tools.ts\";\n\nexport type { ToolResult };\n\n/**\n * Get all available tools (including stdio-only configuration tools).\n *\n * @param options - Optional filtering. When `readOnly` is true, forge_write is excluded.\n */\nexport function getAvailableTools(options?: GetToolsOptions): Tool[] {\n return [...getTools(options), ...STDIO_ONLY_TOOLS];\n}\n\n/**\n * Handle the forge_configure tool.\n */\nexport function handleConfigureTool(args: {\n apiToken?: string;\n organizationSlug?: string;\n}): ToolResult {\n if (!args.apiToken && !args.organizationSlug) {\n return {\n content: [\n {\n type: \"text\",\n text: \"Error: at least one of apiToken or organizationSlug is required.\",\n },\n ],\n structuredContent: {\n success: false,\n error: \"at least one of apiToken or organizationSlug is required.\",\n },\n isError: true,\n };\n }\n\n if (args.apiToken) {\n setToken(args.apiToken);\n }\n if (args.organizationSlug) {\n setOrganizationSlug(args.organizationSlug);\n }\n\n const maskedToken = args.apiToken ? `***${args.apiToken.slice(-4)}` : undefined;\n const data: Record<string, unknown> = {\n success: true,\n message: \"Laravel Forge configuration updated successfully\",\n };\n if (maskedToken) data.apiToken = maskedToken;\n if (args.organizationSlug) data.organizationSlug = args.organizationSlug;\n\n return {\n content: [\n {\n type: \"text\",\n text: JSON.stringify(data, null, 2),\n },\n ],\n structuredContent: data,\n };\n}\n\n/**\n * Handle the forge_get_config tool.\n */\nexport function handleGetConfigTool(): ToolResult {\n const token = getToken();\n const orgSlug = getOrganizationSlug();\n\n const data = {\n apiToken: token ? `***${token.slice(-4)}` : \"not configured\",\n organizationSlug: orgSlug ?? \"not configured\",\n configured: !!token,\n };\n\n return {\n content: [\n {\n type: \"text\",\n text: JSON.stringify(data, null, 2),\n },\n ],\n structuredContent: data,\n };\n}\n\n/**\n * Options for handleToolCall.\n */\nexport interface HandleToolCallOptions {\n /** When true, forge_write is rejected with an error. */\n readOnly?: boolean;\n}\n\n/**\n * Handle a tool call request.\n *\n * Routes to the appropriate handler based on tool name:\n * - forge_configure / forge_get_config — stdio-only config tools\n * - forge — read-only operations (list, get, help, schema)\n * - forge_write — write operations (create, update, delete, deploy, etc.)\n */\nexport async function handleToolCall(\n name: string,\n args: Record<string, unknown>,\n options?: HandleToolCallOptions,\n): Promise<ToolResult> {\n if (name === \"forge_configure\") {\n return handleConfigureTool({\n apiToken: typeof args.apiToken === \"string\" ? args.apiToken : undefined,\n organizationSlug:\n typeof args.organizationSlug === \"string\" ? args.organizationSlug : undefined,\n });\n }\n\n if (name === \"forge_get_config\") {\n return handleGetConfigTool();\n }\n\n // Reject forge_write in read-only mode\n if (name === \"forge_write\" && options?.readOnly) {\n return {\n content: [\n {\n type: \"text\",\n text: \"Error: Server is running in read-only mode. Write operations are disabled.\",\n },\n ],\n structuredContent: {\n success: false,\n error: \"Server is running in read-only mode. Write operations are disabled.\",\n },\n isError: true,\n };\n }\n\n // Both forge and forge_write require authentication\n if (name === \"forge\" || name === \"forge_write\") {\n const apiToken = getToken();\n if (!apiToken) {\n return {\n content: [\n {\n type: \"text\",\n text: 'Error: Forge API token not configured. Use \"forge_configure\" tool or set FORGE_API_TOKEN environment variable.',\n },\n ],\n structuredContent: {\n success: false,\n error:\n 'Forge API token not configured. Use \"forge_configure\" tool or set FORGE_API_TOKEN environment variable.',\n },\n isError: true,\n };\n }\n\n // Organization slug can come from:\n // 1. Tool args (per-request override, highest priority)\n // 2. Config/env (via getOrganizationSlug)\n const orgSlugFromArgs =\n typeof args.organizationSlug === \"string\" ? args.organizationSlug : undefined;\n const orgSlugFromConfig = getOrganizationSlug() ?? undefined;\n\n return executeToolWithCredentials(name, args, {\n apiToken,\n organizationSlug: orgSlugFromArgs ?? orgSlugFromConfig,\n });\n }\n\n return {\n content: [\n {\n type: \"text\",\n text: `Error: Unknown tool \"${name}\".`,\n },\n ],\n structuredContent: { success: false, error: `Unknown tool \"${name}\".` },\n isError: true,\n };\n}\n","#!/usr/bin/env node\n/* eslint-disable typescript-eslint/no-deprecated -- Using low-level Server for StdioServerTransport */\n\n/**\n * Forge MCP Server — Stdio Transport\n *\n * This is the local execution mode using stdio transport.\n * For remote HTTP deployment, use server.ts instead.\n *\n * Usage:\n * npx @studiometa/forge-mcp\n * npx @studiometa/forge-mcp --read-only\n * FORGE_READ_ONLY=true npx @studiometa/forge-mcp\n *\n * Or in Claude Desktop config:\n * {\n * \"mcpServers\": {\n * \"forge\": {\n * \"command\": \"forge-mcp\",\n * \"args\": [\"--read-only\"],\n * \"env\": { \"FORGE_API_TOKEN\": \"your-token\" }\n * }\n * }\n * }\n */\n\n// Using low-level Server for advanced transport handling (StdioServerTransport)\n// eslint-disable-next-line typescript-eslint/no-deprecated\nimport { Server } from \"@modelcontextprotocol/sdk/server/index.js\";\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport {\n CallToolRequestSchema,\n ListToolsRequestSchema,\n type CallToolResult,\n} from \"@modelcontextprotocol/sdk/types.js\";\n\nimport { parseReadOnlyFlag } from \"./flags.ts\";\nimport { INSTRUCTIONS } from \"./instructions.ts\";\nimport { getAvailableTools, handleToolCall } from \"./stdio.ts\";\nimport { VERSION } from \"./version.ts\";\n\n// Re-export so consumers can still import from the main entry point\nexport { parseReadOnlyFlag } from \"./flags.ts\";\n\n/**\n * Options for the stdio MCP server.\n */\nexport interface StdioServerOptions {\n /** When true, forge_write tool is not registered and write operations are rejected. */\n readOnly?: boolean;\n}\n\n/**\n * Create and configure the MCP server.\n */\nexport function createStdioServer(options?: StdioServerOptions): Server {\n const readOnly = options?.readOnly ?? false;\n\n const server = new Server(\n {\n name: \"forge-mcp\",\n version: VERSION,\n },\n {\n capabilities: {\n tools: {},\n },\n instructions: INSTRUCTIONS,\n },\n );\n\n server.setRequestHandler(ListToolsRequestSchema, async () => {\n return { tools: getAvailableTools({ readOnly }) };\n });\n\n server.setRequestHandler(CallToolRequestSchema, async (request) => {\n const { name, arguments: args } = request.params;\n\n try {\n const result = await handleToolCall(name, args ?? {}, {\n readOnly,\n });\n return result as CallToolResult;\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n return {\n content: [{ type: \"text\" as const, text: `Error: ${message}` }],\n structuredContent: { success: false, error: message },\n isError: true,\n };\n }\n });\n\n return server;\n}\n\n/**\n * Start the stdio server.\n */\nexport async function startStdioServer(options?: StdioServerOptions): Promise<void> {\n const server = createStdioServer(options);\n const transport = new StdioServerTransport();\n await server.connect(transport);\n const mode = options?.readOnly ? \" (read-only)\" : \"\";\n console.error(`Forge MCP server v${VERSION} running on stdio${mode}`);\n}\n\n// Start server when run directly\nconst isMainModule =\n import.meta.url === `file://${process.argv[1]}` ||\n process.argv[1]?.endsWith(\"/forge-mcp\") ||\n process.argv[1]?.endsWith(\"\\\\forge-mcp\");\n\nif (isMainModule) {\n const readOnly = parseReadOnlyFlag();\n startStdioServer({ readOnly }).catch((error) => {\n console.error(\"Fatal error:\", error);\n process.exit(1);\n });\n}\n"],"mappings":";;;;;;;;;;;;AAqBA,SAAgB,kBAAkB,SAAmC;AACnE,QAAO,CAAC,GAAG,SAAS,QAAQ,EAAE,GAAG,iBAAiB;;;;;AAMpD,SAAgB,oBAAoB,MAGrB;AACb,KAAI,CAAC,KAAK,YAAY,CAAC,KAAK,iBAC1B,QAAO;EACL,SAAS,CACP;GACE,MAAM;GACN,MAAM;GACP,CACF;EACD,mBAAmB;GACjB,SAAS;GACT,OAAO;GACR;EACD,SAAS;EACV;AAGH,KAAI,KAAK,SACP,UAAS,KAAK,SAAS;AAEzB,KAAI,KAAK,iBACP,qBAAoB,KAAK,iBAAiB;CAG5C,MAAM,cAAc,KAAK,WAAW,MAAM,KAAK,SAAS,MAAM,GAAG,KAAK,KAAA;CACtE,MAAM,OAAgC;EACpC,SAAS;EACT,SAAS;EACV;AACD,KAAI,YAAa,MAAK,WAAW;AACjC,KAAI,KAAK,iBAAkB,MAAK,mBAAmB,KAAK;AAExD,QAAO;EACL,SAAS,CACP;GACE,MAAM;GACN,MAAM,KAAK,UAAU,MAAM,MAAM,EAAE;GACpC,CACF;EACD,mBAAmB;EACpB;;;;;AAMH,SAAgB,sBAAkC;CAChD,MAAM,QAAQ,UAAU;CACxB,MAAM,UAAU,qBAAqB;CAErC,MAAM,OAAO;EACX,UAAU,QAAQ,MAAM,MAAM,MAAM,GAAG,KAAK;EAC5C,kBAAkB,WAAW;EAC7B,YAAY,CAAC,CAAC;EACf;AAED,QAAO;EACL,SAAS,CACP;GACE,MAAM;GACN,MAAM,KAAK,UAAU,MAAM,MAAM,EAAE;GACpC,CACF;EACD,mBAAmB;EACpB;;;;;;;;;;AAmBH,eAAsB,eACpB,MACA,MACA,SACqB;AACrB,KAAI,SAAS,kBACX,QAAO,oBAAoB;EACzB,UAAU,OAAO,KAAK,aAAa,WAAW,KAAK,WAAW,KAAA;EAC9D,kBACE,OAAO,KAAK,qBAAqB,WAAW,KAAK,mBAAmB,KAAA;EACvE,CAAC;AAGJ,KAAI,SAAS,mBACX,QAAO,qBAAqB;AAI9B,KAAI,SAAS,iBAAiB,SAAS,SACrC,QAAO;EACL,SAAS,CACP;GACE,MAAM;GACN,MAAM;GACP,CACF;EACD,mBAAmB;GACjB,SAAS;GACT,OAAO;GACR;EACD,SAAS;EACV;AAIH,KAAI,SAAS,WAAW,SAAS,eAAe;EAC9C,MAAM,WAAW,UAAU;AAC3B,MAAI,CAAC,SACH,QAAO;GACL,SAAS,CACP;IACE,MAAM;IACN,MAAM;IACP,CACF;GACD,mBAAmB;IACjB,SAAS;IACT,OACE;IACH;GACD,SAAS;GACV;EAMH,MAAM,kBACJ,OAAO,KAAK,qBAAqB,WAAW,KAAK,mBAAmB,KAAA;EACtE,MAAM,oBAAoB,qBAAqB,IAAI,KAAA;AAEnD,SAAO,2BAA2B,MAAM,MAAM;GAC5C;GACA,kBAAkB,mBAAmB;GACtC,CAAC;;AAGJ,QAAO;EACL,SAAS,CACP;GACE,MAAM;GACN,MAAM,wBAAwB,KAAK;GACpC,CACF;EACD,mBAAmB;GAAE,SAAS;GAAO,OAAO,iBAAiB,KAAK;GAAK;EACvE,SAAS;EACV;;;;;;;;;;;;;;;;;;;;;;;;;;;ACvIH,SAAgB,kBAAkB,SAAsC;CACtE,MAAM,WAAW,SAAS,YAAY;CAEtC,MAAM,SAAS,IAAI,OACjB;EACE,MAAM;EACN,SAAS;EACV,EACD;EACE,cAAc,EACZ,OAAO,EAAE,EACV;EACD,cAAc;EACf,CACF;AAED,QAAO,kBAAkB,wBAAwB,YAAY;AAC3D,SAAO,EAAE,OAAO,kBAAkB,EAAE,UAAU,CAAC,EAAE;GACjD;AAEF,QAAO,kBAAkB,uBAAuB,OAAO,YAAY;EACjE,MAAM,EAAE,MAAM,WAAW,SAAS,QAAQ;AAE1C,MAAI;AAIF,UAHe,MAAM,eAAe,MAAM,QAAQ,EAAE,EAAE,EACpD,UACD,CAAC;WAEK,OAAO;GACd,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,MAAM;AACtE,UAAO;IACL,SAAS,CAAC;KAAE,MAAM;KAAiB,MAAM,UAAU;KAAW,CAAC;IAC/D,mBAAmB;KAAE,SAAS;KAAO,OAAO;KAAS;IACrD,SAAS;IACV;;GAEH;AAEF,QAAO;;;;;AAMT,eAAsB,iBAAiB,SAA6C;CAClF,MAAM,SAAS,kBAAkB,QAAQ;CACzC,MAAM,YAAY,IAAI,sBAAsB;AAC5C,OAAM,OAAO,QAAQ,UAAU;CAC/B,MAAM,OAAO,SAAS,WAAW,iBAAiB;AAClD,SAAQ,MAAM,qBAAqB,QAAQ,mBAAmB,OAAO;;AASvE,IAJE,OAAO,KAAK,QAAQ,UAAU,QAAQ,KAAK,QAC3C,QAAQ,KAAK,IAAI,SAAS,aAAa,IACvC,QAAQ,KAAK,IAAI,SAAS,cAAc,CAIxC,kBAAiB,EAAE,UADF,mBAAmB,EACP,CAAC,CAAC,OAAO,UAAU;AAC9C,SAAQ,MAAM,gBAAgB,MAAM;AACpC,SAAQ,KAAK,EAAE;EACf"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/stdio.ts","../src/index.ts"],"sourcesContent":["import {\n getToken,\n getOrganizationSlug,\n setToken,\n setOrganizationSlug,\n} from \"@studiometa/forge-api\";\n\nimport type { ToolResult } from \"./handlers/types.ts\";\n\nimport { executeToolWithCredentials } from \"./handlers/index.ts\";\nimport type { Tool } from \"@modelcontextprotocol/sdk/types.js\";\nimport { getTools, STDIO_ONLY_TOOLS } from \"./tools.ts\";\nimport type { GetToolsOptions } from \"./tools.ts\";\n\nexport type { ToolResult };\n\n/**\n * Get all available tools (including stdio-only configuration tools).\n *\n * @param options - Optional filtering. When `readOnly` is true, forge_write is excluded.\n */\nexport function getAvailableTools(options?: GetToolsOptions): Tool[] {\n return [...getTools(options), ...STDIO_ONLY_TOOLS];\n}\n\n/**\n * Handle the forge_configure tool.\n */\nexport function handleConfigureTool(args: {\n apiToken?: string;\n organizationSlug?: string;\n}): ToolResult {\n if (!args.apiToken && !args.organizationSlug) {\n return {\n content: [\n {\n type: \"text\",\n text: \"Error: at least one of apiToken or organizationSlug is required.\",\n },\n ],\n structuredContent: {\n success: false,\n error: \"at least one of apiToken or organizationSlug is required.\",\n },\n isError: true,\n };\n }\n\n if (args.apiToken) {\n setToken(args.apiToken);\n }\n if (args.organizationSlug) {\n setOrganizationSlug(args.organizationSlug);\n }\n\n const maskedToken = args.apiToken ? `***${args.apiToken.slice(-4)}` : undefined;\n const data: Record<string, unknown> = {\n success: true,\n message: \"Laravel Forge configuration updated successfully\",\n };\n if (maskedToken) data.apiToken = maskedToken;\n if (args.organizationSlug) data.organizationSlug = args.organizationSlug;\n\n return {\n content: [\n {\n type: \"text\",\n text: JSON.stringify(data, null, 2),\n },\n ],\n structuredContent: data,\n };\n}\n\n/**\n * Handle the forge_get_config tool.\n */\nexport function handleGetConfigTool(): ToolResult {\n const token = getToken();\n const orgSlug = getOrganizationSlug();\n\n const data = {\n apiToken: token ? `***${token.slice(-4)}` : \"not configured\",\n organizationSlug: orgSlug ?? \"not configured\",\n configured: !!token,\n };\n\n return {\n content: [\n {\n type: \"text\",\n text: JSON.stringify(data, null, 2),\n },\n ],\n structuredContent: data,\n };\n}\n\n/**\n * Options for handleToolCall.\n */\nexport interface HandleToolCallOptions {\n /** When true, forge_write is rejected with an error. */\n readOnly?: boolean;\n}\n\n/**\n * Handle a tool call request.\n *\n * Routes to the appropriate handler based on tool name:\n * - forge_configure / forge_get_config — stdio-only config tools\n * - forge — read-only operations (list, get, help, schema)\n * - forge_write — write operations (create, update, delete, deploy, etc.)\n */\nexport async function handleToolCall(\n name: string,\n args: Record<string, unknown>,\n options?: HandleToolCallOptions,\n): Promise<ToolResult> {\n if (name === \"forge_configure\") {\n return handleConfigureTool({\n apiToken: typeof args.apiToken === \"string\" ? args.apiToken : undefined,\n organizationSlug:\n typeof args.organizationSlug === \"string\" ? args.organizationSlug : undefined,\n });\n }\n\n if (name === \"forge_get_config\") {\n return handleGetConfigTool();\n }\n\n // Reject forge_write in read-only mode\n if (name === \"forge_write\" && options?.readOnly) {\n return {\n content: [\n {\n type: \"text\",\n text: \"Error: Server is running in read-only mode. Write operations are disabled.\",\n },\n ],\n structuredContent: {\n success: false,\n error: \"Server is running in read-only mode. Write operations are disabled.\",\n },\n isError: true,\n };\n }\n\n // Both forge and forge_write require authentication\n if (name === \"forge\" || name === \"forge_write\") {\n const apiToken = getToken();\n if (!apiToken) {\n return {\n content: [\n {\n type: \"text\",\n text: 'Error: Forge API token not configured. Use \"forge_configure\" tool or set FORGE_API_TOKEN environment variable.',\n },\n ],\n structuredContent: {\n success: false,\n error:\n 'Forge API token not configured. Use \"forge_configure\" tool or set FORGE_API_TOKEN environment variable.',\n },\n isError: true,\n };\n }\n\n // Organization slug can come from:\n // 1. Tool args (per-request override, highest priority)\n // 2. Config/env (via getOrganizationSlug)\n const orgSlugFromArgs =\n typeof args.organizationSlug === \"string\" ? args.organizationSlug : undefined;\n const orgSlugFromConfig = getOrganizationSlug() ?? undefined;\n\n return executeToolWithCredentials(name, args, {\n apiToken,\n organizationSlug: orgSlugFromArgs ?? orgSlugFromConfig,\n });\n }\n\n return {\n content: [\n {\n type: \"text\",\n text: `Error: Unknown tool \"${name}\".`,\n },\n ],\n structuredContent: { success: false, error: `Unknown tool \"${name}\".` },\n isError: true,\n };\n}\n","#!/usr/bin/env node\n/* eslint-disable typescript-eslint/no-deprecated -- Using low-level Server for StdioServerTransport */\n\n/**\n * Forge MCP Server — Stdio Transport\n *\n * This is the local execution mode using stdio transport.\n * For remote HTTP deployment, use server.ts instead.\n *\n * Usage:\n * npx @studiometa/forge-mcp\n * npx @studiometa/forge-mcp --read-only\n * FORGE_READ_ONLY=true npx @studiometa/forge-mcp\n *\n * Or in Claude Desktop config:\n * {\n * \"mcpServers\": {\n * \"forge\": {\n * \"command\": \"forge-mcp\",\n * \"args\": [\"--read-only\"],\n * \"env\": { \"FORGE_API_TOKEN\": \"your-token\" }\n * }\n * }\n * }\n */\n\n// Using low-level Server for advanced transport handling (StdioServerTransport)\n// eslint-disable-next-line typescript-eslint/no-deprecated\nimport { Server } from \"@modelcontextprotocol/sdk/server/index.js\";\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport {\n CallToolRequestSchema,\n ListToolsRequestSchema,\n type CallToolResult,\n} from \"@modelcontextprotocol/sdk/types.js\";\n\nimport { parseReadOnlyFlag } from \"./flags.ts\";\nimport { INSTRUCTIONS } from \"./instructions.ts\";\nimport { getAvailableTools, handleToolCall } from \"./stdio.ts\";\nimport { VERSION } from \"./version.ts\";\n\n// Re-export so consumers can still import from the main entry point\nexport { parseReadOnlyFlag } from \"./flags.ts\";\n\n/**\n * Options for the stdio MCP server.\n */\nexport interface StdioServerOptions {\n /** When true, forge_write tool is not registered and write operations are rejected. */\n readOnly?: boolean;\n}\n\n/**\n * Create and configure the MCP server.\n */\nexport function createStdioServer(options?: StdioServerOptions): Server {\n const readOnly = options?.readOnly ?? false;\n\n const server = new Server(\n {\n name: \"forge-mcp\",\n version: VERSION,\n },\n {\n capabilities: {\n tools: {},\n },\n instructions: INSTRUCTIONS,\n },\n );\n\n server.setRequestHandler(ListToolsRequestSchema, async () => {\n return { tools: getAvailableTools({ readOnly }) };\n });\n\n server.setRequestHandler(CallToolRequestSchema, async (request) => {\n const { name, arguments: args } = request.params;\n\n try {\n const result = await handleToolCall(name, args ?? {}, {\n readOnly,\n });\n return result as CallToolResult;\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n return {\n content: [{ type: \"text\" as const, text: `Error: ${message}` }],\n structuredContent: { success: false, error: message },\n isError: true,\n };\n }\n });\n\n return server;\n}\n\n/**\n * Start the stdio server.\n */\nexport async function startStdioServer(options?: StdioServerOptions): Promise<void> {\n const server = createStdioServer(options);\n const transport = new StdioServerTransport();\n await server.connect(transport);\n const mode = options?.readOnly ? \" (read-only)\" : \"\";\n console.error(`Forge MCP server v${VERSION} running on stdio${mode}`);\n}\n\n// Start server when run directly\nconst isMainModule =\n import.meta.url === `file://${process.argv[1]}` ||\n process.argv[1]?.endsWith(\"/forge-mcp\") ||\n process.argv[1]?.endsWith(\"\\\\forge-mcp\");\n\nif (isMainModule) {\n const readOnly = parseReadOnlyFlag();\n startStdioServer({ readOnly }).catch((error) => {\n console.error(\"Fatal error:\", error);\n process.exit(1);\n });\n}\n"],"mappings":"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"}
|
package/dist/oauth.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createAuthCode, decodeAuthCode } from "./crypto.js";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { defineEventHandler, getQuery, readBody } from "h3";
|
|
4
|
+
//#region src/oauth.ts
|
|
4
5
|
/**
|
|
5
6
|
* OAuth 2.1 endpoints for Claude Desktop integration
|
|
6
7
|
*
|
|
@@ -37,7 +38,7 @@ function createAccessToken(apiToken, organizationSlug) {
|
|
|
37
38
|
*
|
|
38
39
|
* MCP clients MUST check this endpoint first for server capabilities.
|
|
39
40
|
*/
|
|
40
|
-
|
|
41
|
+
var oauthMetadataHandler = defineEventHandler((event) => {
|
|
41
42
|
const host = event.req.headers.get("host") || "localhost:3000";
|
|
42
43
|
const baseUrl = `${event.req.headers.get("x-forwarded-proto") || "http"}://${host}`;
|
|
43
44
|
event.res.headers.set("Content-Type", "application/json");
|
|
@@ -61,7 +62,7 @@ const oauthMetadataHandler = defineEventHandler((event) => {
|
|
|
61
62
|
*
|
|
62
63
|
* Tells MCP clients where to find the OAuth authorization server.
|
|
63
64
|
*/
|
|
64
|
-
|
|
65
|
+
var protectedResourceHandler = defineEventHandler((event) => {
|
|
65
66
|
const host = event.req.headers.get("host") || "localhost:3000";
|
|
66
67
|
const baseUrl = `${event.req.headers.get("x-forwarded-proto") || "http"}://${host}`;
|
|
67
68
|
event.res.headers.set("Content-Type", "application/json");
|
|
@@ -81,7 +82,7 @@ const protectedResourceHandler = defineEventHandler((event) => {
|
|
|
81
82
|
* Since we use stateless tokens, we accept any registration and return
|
|
82
83
|
* a generated client_id.
|
|
83
84
|
*/
|
|
84
|
-
|
|
85
|
+
var registerHandler = defineEventHandler(async (event) => {
|
|
85
86
|
event.res.headers.set("Content-Type", "application/json");
|
|
86
87
|
let body;
|
|
87
88
|
try {
|
|
@@ -114,7 +115,7 @@ const registerHandler = defineEventHandler(async (event) => {
|
|
|
114
115
|
* Authorization endpoint — shows login form
|
|
115
116
|
* GET /authorize
|
|
116
117
|
*/
|
|
117
|
-
|
|
118
|
+
var authorizeGetHandler = defineEventHandler((event) => {
|
|
118
119
|
const query = getQuery(event);
|
|
119
120
|
const redirectUri = String(query.redirect_uri ?? "");
|
|
120
121
|
const state = String(query.state ?? "");
|
|
@@ -151,7 +152,7 @@ const authorizeGetHandler = defineEventHandler((event) => {
|
|
|
151
152
|
* Authorization endpoint — process login
|
|
152
153
|
* POST /authorize
|
|
153
154
|
*/
|
|
154
|
-
|
|
155
|
+
var authorizePostHandler = defineEventHandler(async (event) => {
|
|
155
156
|
const { apiToken, organizationSlug, redirectUri, state, codeChallenge, codeChallengeMethod } = await readBody(event) ?? {};
|
|
156
157
|
if (!redirectUri) {
|
|
157
158
|
event.res.headers.set("Content-Type", "text/html; charset=utf-8");
|
|
@@ -201,7 +202,7 @@ const authorizePostHandler = defineEventHandler(async (event) => {
|
|
|
201
202
|
* - authorization_code grant (with PKCE validation)
|
|
202
203
|
* - refresh_token grant
|
|
203
204
|
*/
|
|
204
|
-
|
|
205
|
+
var tokenHandler = defineEventHandler(async (event) => {
|
|
205
206
|
event.res.headers.set("Content-Type", "application/json");
|
|
206
207
|
const { grant_type, code, code_verifier, refresh_token } = await readBody(event) ?? {};
|
|
207
208
|
if (grant_type === "refresh_token") return handleRefreshToken(event, refresh_token);
|
|
@@ -587,6 +588,7 @@ function renderErrorPage(message) {
|
|
|
587
588
|
</body>
|
|
588
589
|
</html>`;
|
|
589
590
|
}
|
|
591
|
+
//#endregion
|
|
590
592
|
export { authorizeGetHandler, authorizePostHandler, createAccessToken, createS256Challenge, oauthMetadataHandler, protectedResourceHandler, registerHandler, tokenHandler };
|
|
591
593
|
|
|
592
594
|
//# sourceMappingURL=oauth.js.map
|
package/dist/oauth.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"oauth.js","names":[],"sources":["../src/oauth.ts"],"sourcesContent":["/**\n * OAuth 2.1 endpoints for Claude Desktop integration\n *\n * Implements OAuth 2.1 with PKCE as specified in the MCP authorization spec.\n * Uses stateless encrypted tokens — no server-side storage required.\n *\n * Spec: https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization\n *\n * Flow:\n * 1. Claude redirects user to /authorize with OAuth params (including PKCE)\n * 2. User enters their Forge API token and optional organization slug in a login form\n * 3. Server encrypts the token + PKCE challenge into an authorization code\n * 4. Redirects back to Claude with the code\n * 5. Claude exchanges code for access token via /token (with code_verifier)\n * 6. Server validates PKCE and returns base64-encoded access token\n */\n\nimport { defineEventHandler, getQuery, readBody, type H3Event } from \"h3\";\nimport { createHash } from \"node:crypto\";\n\nimport { createAuthCode, decodeAuthCode } from \"./crypto.ts\";\n\n/**\n * Create a base64-encoded access token from Forge credentials.\n *\n * Backward compatibility:\n * - without organizationSlug: base64(apiToken)\n * - with organizationSlug: base64(JSON.stringify({ apiToken, organizationSlug }))\n */\nexport function createAccessToken(apiToken: string, organizationSlug?: string): string {\n const payload = organizationSlug ? JSON.stringify({ apiToken, organizationSlug }) : apiToken;\n return Buffer.from(payload).toString(\"base64\");\n}\n\n/**\n * OAuth metadata for discovery (RFC 8414)\n * GET /.well-known/oauth-authorization-server\n *\n * MCP clients MUST check this endpoint first for server capabilities.\n */\nexport const oauthMetadataHandler = defineEventHandler((event: H3Event) => {\n const host = event.req.headers.get(\"host\") || \"localhost:3000\";\n const protocol = event.req.headers.get(\"x-forwarded-proto\") || \"http\";\n const baseUrl = `${protocol}://${host}`;\n\n event.res.headers.set(\"Content-Type\", \"application/json\");\n event.res.headers.set(\"Cache-Control\", \"public, max-age=3600\");\n\n return {\n // Required fields per RFC 8414\n issuer: baseUrl,\n authorization_endpoint: `${baseUrl}/authorize`,\n token_endpoint: `${baseUrl}/token`,\n response_types_supported: [\"code\"],\n\n // OAuth 2.1 / MCP requirements\n grant_types_supported: [\"authorization_code\", \"refresh_token\"],\n code_challenge_methods_supported: [\"S256\"],\n token_endpoint_auth_methods_supported: [\"none\"], // Public client\n\n // Optional but useful\n registration_endpoint: `${baseUrl}/register`,\n scopes_supported: [\"forge\"],\n service_documentation: \"https://github.com/studiometa/forge-tools\",\n };\n});\n\n/**\n * Protected resource metadata (RFC 9728)\n * GET /.well-known/oauth-protected-resource\n *\n * Tells MCP clients where to find the OAuth authorization server.\n */\nexport const protectedResourceHandler = defineEventHandler((event: H3Event) => {\n const host = event.req.headers.get(\"host\") || \"localhost:3000\";\n const protocol = event.req.headers.get(\"x-forwarded-proto\") || \"http\";\n const baseUrl = `${protocol}://${host}`;\n\n event.res.headers.set(\"Content-Type\", \"application/json\");\n event.res.headers.set(\"Cache-Control\", \"public, max-age=3600\");\n\n return {\n resource: `${baseUrl}/mcp`,\n authorization_servers: [baseUrl],\n bearer_methods_supported: [\"header\"],\n scopes_supported: [\"forge\"],\n };\n});\n\n/**\n * Dynamic Client Registration endpoint (RFC 7591)\n * POST /register\n *\n * MCP servers SHOULD support DCR to allow clients to register automatically.\n * Since we use stateless tokens, we accept any registration and return\n * a generated client_id.\n */\nexport const registerHandler = defineEventHandler(async (event: H3Event) => {\n event.res.headers.set(\"Content-Type\", \"application/json\");\n\n let body: Record<string, unknown>;\n try {\n const raw: unknown = await readBody(event);\n body = typeof raw === \"object\" && raw !== null ? (raw as Record<string, unknown>) : {};\n } catch {\n event.res.status = 400;\n return {\n error: \"invalid_request\",\n error_description: \"Invalid JSON body\",\n };\n }\n\n // Extract client metadata\n const clientName = typeof body.client_name === \"string\" ? body.client_name : \"MCP Client\";\n const redirectUris = Array.isArray(body.redirect_uris) ? (body.redirect_uris as string[]) : [];\n\n // Generate a client_id based on the registration\n // Since we're stateless, we encode minimal info in the client_id\n const clientId = Buffer.from(\n JSON.stringify({\n name: clientName,\n ts: Date.now(),\n }),\n ).toString(\"base64url\");\n\n event.res.status = 201;\n return {\n client_id: clientId,\n client_name: clientName,\n redirect_uris: redirectUris,\n token_endpoint_auth_method: \"none\",\n grant_types: [\"authorization_code\", \"refresh_token\"],\n response_types: [\"code\"],\n };\n});\n\n/**\n * Authorization endpoint — shows login form\n * GET /authorize\n */\nexport const authorizeGetHandler = defineEventHandler((event: H3Event) => {\n const query = getQuery(event);\n\n // Extract OAuth parameters\n const redirectUri = String(query.redirect_uri ?? \"\");\n const state = String(query.state ?? \"\");\n const codeChallenge = String(query.code_challenge ?? \"\");\n const codeChallengeMethod = String(query.code_challenge_method ?? \"\");\n\n // Validate required parameters per OAuth 2.1\n if (!redirectUri) {\n event.res.headers.set(\"Content-Type\", \"text/html; charset=utf-8\");\n event.res.status = 400;\n return renderErrorPage(\"Missing required parameter: redirect_uri\");\n }\n\n // PKCE is REQUIRED for public clients per MCP spec\n if (!codeChallenge) {\n const errorUrl = new URL(redirectUri);\n errorUrl.searchParams.set(\"error\", \"invalid_request\");\n errorUrl.searchParams.set(\"error_description\", \"code_challenge is required\");\n if (state) errorUrl.searchParams.set(\"state\", state);\n return Response.redirect(errorUrl.toString(), 302);\n }\n\n if (codeChallengeMethod && codeChallengeMethod !== \"S256\") {\n const errorUrl = new URL(redirectUri);\n errorUrl.searchParams.set(\"error\", \"invalid_request\");\n errorUrl.searchParams.set(\"error_description\", \"Only S256 code_challenge_method is supported\");\n if (state) errorUrl.searchParams.set(\"state\", state);\n return Response.redirect(errorUrl.toString(), 302);\n }\n\n event.res.headers.set(\"Content-Type\", \"text/html; charset=utf-8\");\n\n return renderLoginForm({\n redirectUri,\n state,\n codeChallenge,\n codeChallengeMethod: codeChallengeMethod || \"S256\",\n });\n});\n\n/**\n * Authorization endpoint — process login\n * POST /authorize\n */\nexport const authorizePostHandler = defineEventHandler(async (event: H3Event) => {\n const body = (await readBody<Record<string, string>>(event)) ?? {};\n\n const { apiToken, organizationSlug, redirectUri, state, codeChallenge, codeChallengeMethod } =\n body;\n\n // Validate redirect URI first (security requirement)\n if (!redirectUri) {\n event.res.headers.set(\"Content-Type\", \"text/html; charset=utf-8\");\n event.res.status = 400;\n return renderErrorPage(\"Missing redirect_uri parameter\");\n }\n\n // Validate redirect URI format (must be HTTPS or localhost)\n try {\n const uri = new URL(redirectUri);\n const isLocalhost = uri.hostname === \"localhost\" || uri.hostname === \"127.0.0.1\";\n const isHttps = uri.protocol === \"https:\";\n if (!isLocalhost && !isHttps) {\n event.res.status = 400;\n return renderErrorPage(\"redirect_uri must be HTTPS or localhost\");\n }\n } catch {\n event.res.status = 400;\n return renderErrorPage(\"Invalid redirect_uri format\");\n }\n\n // Validate required credentials\n if (!apiToken) {\n event.res.headers.set(\"Content-Type\", \"text/html; charset=utf-8\");\n return renderLoginForm({\n redirectUri,\n state,\n codeChallenge,\n codeChallengeMethod,\n organizationSlug,\n error: \"Forge API Token is required\",\n });\n }\n\n // Create encrypted authorization code with PKCE challenge\n const code = createAuthCode({\n apiToken,\n organizationSlug: organizationSlug || undefined,\n codeChallenge,\n codeChallengeMethod: codeChallengeMethod || \"S256\",\n });\n\n // Build redirect URL with authorization code\n const redirectUrl = new URL(redirectUri);\n redirectUrl.searchParams.set(\"code\", code);\n if (state) {\n redirectUrl.searchParams.set(\"state\", state);\n }\n\n // Show success page with auto-redirect\n event.res.headers.set(\"Content-Type\", \"text/html; charset=utf-8\");\n return renderSuccessPage(redirectUrl.toString());\n});\n\n/**\n * Token endpoint — exchange code for access token\n * POST /token\n *\n * Supports:\n * - authorization_code grant (with PKCE validation)\n * - refresh_token grant\n */\nexport const tokenHandler = defineEventHandler(async (event: H3Event) => {\n event.res.headers.set(\"Content-Type\", \"application/json\");\n\n // h3 auto-parses both JSON and URL-encoded bodies into objects\n const body = (await readBody<Record<string, string>>(event)) ?? {};\n\n const { grant_type, code, code_verifier, refresh_token } = body;\n\n // Handle refresh token grant\n if (grant_type === \"refresh_token\") {\n return handleRefreshToken(event, refresh_token);\n }\n\n // Validate authorization code grant\n if (grant_type !== \"authorization_code\") {\n event.res.status = 400;\n return {\n error: \"unsupported_grant_type\",\n error_description: \"Supported grant types: authorization_code, refresh_token\",\n };\n }\n\n if (!code) {\n event.res.status = 400;\n return {\n error: \"invalid_request\",\n error_description: \"Missing authorization code\",\n };\n }\n\n if (!code_verifier) {\n event.res.status = 400;\n return {\n error: \"invalid_request\",\n error_description: \"Missing code_verifier (PKCE required)\",\n };\n }\n\n try {\n // Decode the authorization code\n const payload = decodeAuthCode(code);\n\n // Validate PKCE: SHA256(code_verifier) must equal code_challenge\n if (payload.codeChallenge) {\n const expectedChallenge = createS256Challenge(code_verifier);\n if (expectedChallenge !== payload.codeChallenge) {\n event.res.status = 400;\n return {\n error: \"invalid_grant\",\n error_description: \"Invalid code_verifier\",\n };\n }\n }\n\n // Create access token with embedded credentials when organizationSlug is present.\n const accessToken = createAccessToken(payload.apiToken, payload.organizationSlug);\n\n // Create refresh token (encrypted credentials, longer expiry)\n const refreshToken = createAuthCode(\n { apiToken: payload.apiToken, organizationSlug: payload.organizationSlug },\n 86400 * 30, // 30 days\n );\n\n return {\n access_token: accessToken,\n token_type: \"Bearer\",\n expires_in: 3600, // 1 hour\n refresh_token: refreshToken,\n };\n } catch (error) {\n event.res.status = 400;\n return {\n error: \"invalid_grant\",\n error_description: error instanceof Error ? error.message : \"Invalid authorization code\",\n };\n }\n});\n\n/**\n * Handle refresh token grant\n */\nfunction handleRefreshToken(event: H3Event, refreshToken: string | undefined) {\n if (!refreshToken) {\n event.res.status = 400;\n return {\n error: \"invalid_request\",\n error_description: \"Missing refresh_token\",\n };\n }\n\n try {\n // Decode refresh token (it's just an encrypted auth code with longer expiry)\n const payload = decodeAuthCode(refreshToken);\n\n // Create new access token\n const accessToken = createAccessToken(payload.apiToken, payload.organizationSlug);\n\n // Create new refresh token (rotate for security)\n const newRefreshToken = createAuthCode(\n { apiToken: payload.apiToken, organizationSlug: payload.organizationSlug },\n 86400 * 30, // 30 days\n );\n\n return {\n access_token: accessToken,\n token_type: \"Bearer\",\n expires_in: 3600,\n refresh_token: newRefreshToken,\n };\n } catch (error) {\n event.res.status = 400;\n return {\n error: \"invalid_grant\",\n error_description: error instanceof Error ? error.message : \"Invalid refresh token\",\n };\n }\n}\n\n/**\n * Create S256 PKCE challenge from verifier\n * SHA256(code_verifier) encoded as base64url\n */\nexport function createS256Challenge(codeVerifier: string): string {\n return createHash(\"sha256\").update(codeVerifier).digest(\"base64url\");\n}\n\n/**\n * Escape HTML special characters\n */\nfunction escapeHtml(str: string): string {\n return str\n .replaceAll(\"&\", \"&\")\n .replaceAll(\"<\", \"<\")\n .replaceAll(\">\", \">\")\n .replaceAll('\"', \""\")\n .replaceAll(\"'\", \"'\");\n}\n\n/**\n * Render the login form HTML\n */\nfunction renderLoginForm(params: {\n redirectUri?: string;\n state?: string;\n codeChallenge?: string;\n codeChallengeMethod?: string;\n organizationSlug?: string;\n error?: string;\n}): string {\n const { redirectUri, state, codeChallenge, codeChallengeMethod, organizationSlug, error } =\n params;\n\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Connect to Laravel Forge</title>\n <style>\n * { box-sizing: border-box; margin: 0; padding: 0; }\n body {\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n background: linear-gradient(135deg, #18b69b 0%, #0e7460 100%);\n min-height: 100vh;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 20px;\n }\n .container {\n background: white;\n border-radius: 16px;\n box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);\n padding: 40px;\n width: 100%;\n max-width: 420px;\n }\n .logo {\n text-align: center;\n margin-bottom: 24px;\n }\n .logo svg { width: 48px; height: 48px; }\n h1 {\n text-align: center;\n color: #1a1a2e;\n font-size: 24px;\n margin-bottom: 8px;\n }\n .subtitle {\n text-align: center;\n color: #666;\n font-size: 14px;\n margin-bottom: 32px;\n }\n .error {\n background: #fee2e2;\n border: 1px solid #fecaca;\n color: #dc2626;\n padding: 12px 16px;\n border-radius: 8px;\n margin-bottom: 24px;\n font-size: 14px;\n }\n .notice {\n background: #f0fdf4;\n border: 1px solid #bbf7d0;\n color: #166534;\n padding: 12px 16px;\n border-radius: 8px;\n margin-bottom: 24px;\n font-size: 13px;\n line-height: 1.4;\n }\n .form-group { margin-bottom: 20px; }\n label {\n display: block;\n font-size: 14px;\n font-weight: 500;\n color: #374151;\n margin-bottom: 6px;\n }\n input {\n width: 100%;\n padding: 12px 16px;\n border: 1px solid #d1d5db;\n border-radius: 8px;\n font-size: 16px;\n transition: border-color 0.2s, box-shadow 0.2s;\n }\n input:focus {\n outline: none;\n border-color: #18b69b;\n box-shadow: 0 0 0 3px rgba(24, 182, 155, 0.2);\n }\n input::placeholder { color: #9ca3af; }\n .help-text {\n font-size: 12px;\n color: #6b7280;\n margin-top: 4px;\n }\n .help-text a { color: #18b69b; text-decoration: none; }\n .help-text a:hover { text-decoration: underline; }\n button {\n width: 100%;\n padding: 14px 24px;\n background: linear-gradient(135deg, #18b69b 0%, #0e7460 100%);\n color: white;\n border: none;\n border-radius: 8px;\n font-size: 16px;\n font-weight: 600;\n cursor: pointer;\n transition: transform 0.2s, box-shadow 0.2s;\n }\n button:hover {\n transform: translateY(-1px);\n box-shadow: 0 4px 12px rgba(24, 182, 155, 0.4);\n }\n button:active { transform: translateY(0); }\n .footer {\n text-align: center;\n margin-top: 24px;\n font-size: 12px;\n color: #9ca3af;\n }\n .footer a { color: #18b69b; text-decoration: none; }\n .footer a:hover { text-decoration: underline; }\n </style>\n</head>\n<body>\n <div class=\"container\">\n <div class=\"logo\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n <path d=\"M12 2L2 7L12 12L22 7L12 2Z\" stroke=\"#18b69b\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n <path d=\"M2 17L12 22L22 17\" stroke=\"#0e7460\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n <path d=\"M2 12L12 17L22 12\" stroke=\"#18b69b\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n </svg>\n </div>\n <h1>Connect to Laravel Forge</h1>\n <p class=\"subtitle\">Enter your Forge API token to connect with Claude</p>\n\n ${error ? `<div class=\"error\">${escapeHtml(error)}</div>` : \"\"}\n\n <div class=\"notice\">\n <strong>Your token is not stored on this server.</strong> It is encrypted into the authorization code and sent back to Claude Desktop, which holds it in its own secure storage.\n </div>\n\n <form method=\"POST\" action=\"/authorize\">\n <input type=\"hidden\" name=\"redirectUri\" value=\"${escapeHtml(redirectUri || \"\")}\">\n <input type=\"hidden\" name=\"state\" value=\"${escapeHtml(state || \"\")}\">\n <input type=\"hidden\" name=\"codeChallenge\" value=\"${escapeHtml(codeChallenge || \"\")}\">\n <input type=\"hidden\" name=\"codeChallengeMethod\" value=\"${escapeHtml(codeChallengeMethod || \"S256\")}\">\n\n <div class=\"form-group\">\n <label for=\"apiToken\">Forge API Token *</label>\n <input type=\"password\" id=\"apiToken\" name=\"apiToken\" required placeholder=\"Enter your Forge API token\">\n <p class=\"help-text\">\n Generate at <a href=\"https://forge.laravel.com/user-profile/api\" target=\"_blank\" rel=\"noopener\">forge.laravel.com → API Tokens</a>\n </p>\n </div>\n\n <div class=\"form-group\">\n <label for=\"organizationSlug\">Organization Slug</label>\n <input type=\"text\" id=\"organizationSlug\" name=\"organizationSlug\" value=\"${escapeHtml(organizationSlug || \"\")}\" placeholder=\"e.g. studio-meta\" autocapitalize=\"off\" autocorrect=\"off\" spellcheck=\"false\">\n <p class=\"help-text\">Optional, but recommended as the default org for Claude MCP requests.</p>\n </div>\n\n <button type=\"submit\">Connect to Forge</button>\n </form>\n\n <p class=\"footer\">\n Powered by <a href=\"https://github.com/studiometa/forge-tools\" target=\"_blank\" rel=\"noopener\">forge-tools</a>\n </p>\n </div>\n</body>\n</html>`;\n}\n\n/**\n * Render success page with auto-redirect\n */\nfunction renderSuccessPage(redirectUrl: string): string {\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <meta http-equiv=\"refresh\" content=\"2;url=${escapeHtml(redirectUrl)}\">\n <title>Connected — Forge MCP</title>\n <style>\n * { box-sizing: border-box; margin: 0; padding: 0; }\n body {\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n background: linear-gradient(135deg, #18b69b 0%, #0e7460 100%);\n min-height: 100vh;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 20px;\n }\n .container {\n background: white;\n border-radius: 16px;\n box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);\n padding: 40px;\n width: 100%;\n max-width: 420px;\n text-align: center;\n }\n .success-icon {\n width: 64px;\n height: 64px;\n margin: 0 auto 24px;\n background: linear-gradient(135deg, #10b981 0%, #059669 100%);\n border-radius: 50%;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n .success-icon svg { width: 32px; height: 32px; stroke: white; }\n h1 { color: #1a1a2e; font-size: 24px; margin-bottom: 8px; }\n .message { color: #666; font-size: 14px; margin-bottom: 24px; }\n .spinner {\n width: 24px;\n height: 24px;\n border: 3px solid #e5e7eb;\n border-top-color: #18b69b;\n border-radius: 50%;\n animation: spin 1s linear infinite;\n margin: 0 auto 16px;\n }\n @keyframes spin { to { transform: rotate(360deg); } }\n .redirect-text { color: #9ca3af; font-size: 13px; margin-bottom: 16px; }\n .manual-link { color: #18b69b; text-decoration: none; font-size: 14px; }\n .manual-link:hover { text-decoration: underline; }\n </style>\n</head>\n<body>\n <div class=\"container\">\n <div class=\"success-icon\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n <path d=\"M20 6L9 17L4 12\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n </svg>\n </div>\n <h1>Successfully Connected!</h1>\n <p class=\"message\">Your Forge API token has been encrypted and transmitted securely.</p>\n <div class=\"spinner\"></div>\n <p class=\"redirect-text\">Redirecting to Claude Desktop...</p>\n <a href=\"${escapeHtml(redirectUrl)}\" class=\"manual-link\">Click here if not redirected automatically</a>\n </div>\n <script>\n setTimeout(function() {\n window.location.href = ${JSON.stringify(redirectUrl)};\n }, 2000);\n </script>\n</body>\n</html>`;\n}\n\n/**\n * Render error page\n */\nfunction renderErrorPage(message: string): string {\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Error — Forge MCP</title>\n <style>\n body {\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n background: #f3f4f6;\n min-height: 100vh;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 20px;\n }\n .container {\n background: white;\n border-radius: 16px;\n box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);\n padding: 40px;\n text-align: center;\n max-width: 400px;\n }\n h1 { color: #dc2626; margin-bottom: 16px; }\n p { color: #6b7280; }\n </style>\n</head>\n<body>\n <div class=\"container\">\n <h1>Error</h1>\n <p>${escapeHtml(message)}</p>\n </div>\n</body>\n</html>`;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"oauth.js","names":[],"sources":["../src/oauth.ts"],"sourcesContent":["/**\n * OAuth 2.1 endpoints for Claude Desktop integration\n *\n * Implements OAuth 2.1 with PKCE as specified in the MCP authorization spec.\n * Uses stateless encrypted tokens — no server-side storage required.\n *\n * Spec: https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization\n *\n * Flow:\n * 1. Claude redirects user to /authorize with OAuth params (including PKCE)\n * 2. User enters their Forge API token and optional organization slug in a login form\n * 3. Server encrypts the token + PKCE challenge into an authorization code\n * 4. Redirects back to Claude with the code\n * 5. Claude exchanges code for access token via /token (with code_verifier)\n * 6. Server validates PKCE and returns base64-encoded access token\n */\n\nimport { defineEventHandler, getQuery, readBody, type H3Event } from \"h3\";\nimport { createHash } from \"node:crypto\";\n\nimport { createAuthCode, decodeAuthCode } from \"./crypto.ts\";\n\n/**\n * Create a base64-encoded access token from Forge credentials.\n *\n * Backward compatibility:\n * - without organizationSlug: base64(apiToken)\n * - with organizationSlug: base64(JSON.stringify({ apiToken, organizationSlug }))\n */\nexport function createAccessToken(apiToken: string, organizationSlug?: string): string {\n const payload = organizationSlug ? JSON.stringify({ apiToken, organizationSlug }) : apiToken;\n return Buffer.from(payload).toString(\"base64\");\n}\n\n/**\n * OAuth metadata for discovery (RFC 8414)\n * GET /.well-known/oauth-authorization-server\n *\n * MCP clients MUST check this endpoint first for server capabilities.\n */\nexport const oauthMetadataHandler = defineEventHandler((event: H3Event) => {\n const host = event.req.headers.get(\"host\") || \"localhost:3000\";\n const protocol = event.req.headers.get(\"x-forwarded-proto\") || \"http\";\n const baseUrl = `${protocol}://${host}`;\n\n event.res.headers.set(\"Content-Type\", \"application/json\");\n event.res.headers.set(\"Cache-Control\", \"public, max-age=3600\");\n\n return {\n // Required fields per RFC 8414\n issuer: baseUrl,\n authorization_endpoint: `${baseUrl}/authorize`,\n token_endpoint: `${baseUrl}/token`,\n response_types_supported: [\"code\"],\n\n // OAuth 2.1 / MCP requirements\n grant_types_supported: [\"authorization_code\", \"refresh_token\"],\n code_challenge_methods_supported: [\"S256\"],\n token_endpoint_auth_methods_supported: [\"none\"], // Public client\n\n // Optional but useful\n registration_endpoint: `${baseUrl}/register`,\n scopes_supported: [\"forge\"],\n service_documentation: \"https://github.com/studiometa/forge-tools\",\n };\n});\n\n/**\n * Protected resource metadata (RFC 9728)\n * GET /.well-known/oauth-protected-resource\n *\n * Tells MCP clients where to find the OAuth authorization server.\n */\nexport const protectedResourceHandler = defineEventHandler((event: H3Event) => {\n const host = event.req.headers.get(\"host\") || \"localhost:3000\";\n const protocol = event.req.headers.get(\"x-forwarded-proto\") || \"http\";\n const baseUrl = `${protocol}://${host}`;\n\n event.res.headers.set(\"Content-Type\", \"application/json\");\n event.res.headers.set(\"Cache-Control\", \"public, max-age=3600\");\n\n return {\n resource: `${baseUrl}/mcp`,\n authorization_servers: [baseUrl],\n bearer_methods_supported: [\"header\"],\n scopes_supported: [\"forge\"],\n };\n});\n\n/**\n * Dynamic Client Registration endpoint (RFC 7591)\n * POST /register\n *\n * MCP servers SHOULD support DCR to allow clients to register automatically.\n * Since we use stateless tokens, we accept any registration and return\n * a generated client_id.\n */\nexport const registerHandler = defineEventHandler(async (event: H3Event) => {\n event.res.headers.set(\"Content-Type\", \"application/json\");\n\n let body: Record<string, unknown>;\n try {\n const raw: unknown = await readBody(event);\n body = typeof raw === \"object\" && raw !== null ? (raw as Record<string, unknown>) : {};\n } catch {\n event.res.status = 400;\n return {\n error: \"invalid_request\",\n error_description: \"Invalid JSON body\",\n };\n }\n\n // Extract client metadata\n const clientName = typeof body.client_name === \"string\" ? body.client_name : \"MCP Client\";\n const redirectUris = Array.isArray(body.redirect_uris) ? (body.redirect_uris as string[]) : [];\n\n // Generate a client_id based on the registration\n // Since we're stateless, we encode minimal info in the client_id\n const clientId = Buffer.from(\n JSON.stringify({\n name: clientName,\n ts: Date.now(),\n }),\n ).toString(\"base64url\");\n\n event.res.status = 201;\n return {\n client_id: clientId,\n client_name: clientName,\n redirect_uris: redirectUris,\n token_endpoint_auth_method: \"none\",\n grant_types: [\"authorization_code\", \"refresh_token\"],\n response_types: [\"code\"],\n };\n});\n\n/**\n * Authorization endpoint — shows login form\n * GET /authorize\n */\nexport const authorizeGetHandler = defineEventHandler((event: H3Event) => {\n const query = getQuery(event);\n\n // Extract OAuth parameters\n const redirectUri = String(query.redirect_uri ?? \"\");\n const state = String(query.state ?? \"\");\n const codeChallenge = String(query.code_challenge ?? \"\");\n const codeChallengeMethod = String(query.code_challenge_method ?? \"\");\n\n // Validate required parameters per OAuth 2.1\n if (!redirectUri) {\n event.res.headers.set(\"Content-Type\", \"text/html; charset=utf-8\");\n event.res.status = 400;\n return renderErrorPage(\"Missing required parameter: redirect_uri\");\n }\n\n // PKCE is REQUIRED for public clients per MCP spec\n if (!codeChallenge) {\n const errorUrl = new URL(redirectUri);\n errorUrl.searchParams.set(\"error\", \"invalid_request\");\n errorUrl.searchParams.set(\"error_description\", \"code_challenge is required\");\n if (state) errorUrl.searchParams.set(\"state\", state);\n return Response.redirect(errorUrl.toString(), 302);\n }\n\n if (codeChallengeMethod && codeChallengeMethod !== \"S256\") {\n const errorUrl = new URL(redirectUri);\n errorUrl.searchParams.set(\"error\", \"invalid_request\");\n errorUrl.searchParams.set(\"error_description\", \"Only S256 code_challenge_method is supported\");\n if (state) errorUrl.searchParams.set(\"state\", state);\n return Response.redirect(errorUrl.toString(), 302);\n }\n\n event.res.headers.set(\"Content-Type\", \"text/html; charset=utf-8\");\n\n return renderLoginForm({\n redirectUri,\n state,\n codeChallenge,\n codeChallengeMethod: codeChallengeMethod || \"S256\",\n });\n});\n\n/**\n * Authorization endpoint — process login\n * POST /authorize\n */\nexport const authorizePostHandler = defineEventHandler(async (event: H3Event) => {\n const body = (await readBody<Record<string, string>>(event)) ?? {};\n\n const { apiToken, organizationSlug, redirectUri, state, codeChallenge, codeChallengeMethod } =\n body;\n\n // Validate redirect URI first (security requirement)\n if (!redirectUri) {\n event.res.headers.set(\"Content-Type\", \"text/html; charset=utf-8\");\n event.res.status = 400;\n return renderErrorPage(\"Missing redirect_uri parameter\");\n }\n\n // Validate redirect URI format (must be HTTPS or localhost)\n try {\n const uri = new URL(redirectUri);\n const isLocalhost = uri.hostname === \"localhost\" || uri.hostname === \"127.0.0.1\";\n const isHttps = uri.protocol === \"https:\";\n if (!isLocalhost && !isHttps) {\n event.res.status = 400;\n return renderErrorPage(\"redirect_uri must be HTTPS or localhost\");\n }\n } catch {\n event.res.status = 400;\n return renderErrorPage(\"Invalid redirect_uri format\");\n }\n\n // Validate required credentials\n if (!apiToken) {\n event.res.headers.set(\"Content-Type\", \"text/html; charset=utf-8\");\n return renderLoginForm({\n redirectUri,\n state,\n codeChallenge,\n codeChallengeMethod,\n organizationSlug,\n error: \"Forge API Token is required\",\n });\n }\n\n // Create encrypted authorization code with PKCE challenge\n const code = createAuthCode({\n apiToken,\n organizationSlug: organizationSlug || undefined,\n codeChallenge,\n codeChallengeMethod: codeChallengeMethod || \"S256\",\n });\n\n // Build redirect URL with authorization code\n const redirectUrl = new URL(redirectUri);\n redirectUrl.searchParams.set(\"code\", code);\n if (state) {\n redirectUrl.searchParams.set(\"state\", state);\n }\n\n // Show success page with auto-redirect\n event.res.headers.set(\"Content-Type\", \"text/html; charset=utf-8\");\n return renderSuccessPage(redirectUrl.toString());\n});\n\n/**\n * Token endpoint — exchange code for access token\n * POST /token\n *\n * Supports:\n * - authorization_code grant (with PKCE validation)\n * - refresh_token grant\n */\nexport const tokenHandler = defineEventHandler(async (event: H3Event) => {\n event.res.headers.set(\"Content-Type\", \"application/json\");\n\n // h3 auto-parses both JSON and URL-encoded bodies into objects\n const body = (await readBody<Record<string, string>>(event)) ?? {};\n\n const { grant_type, code, code_verifier, refresh_token } = body;\n\n // Handle refresh token grant\n if (grant_type === \"refresh_token\") {\n return handleRefreshToken(event, refresh_token);\n }\n\n // Validate authorization code grant\n if (grant_type !== \"authorization_code\") {\n event.res.status = 400;\n return {\n error: \"unsupported_grant_type\",\n error_description: \"Supported grant types: authorization_code, refresh_token\",\n };\n }\n\n if (!code) {\n event.res.status = 400;\n return {\n error: \"invalid_request\",\n error_description: \"Missing authorization code\",\n };\n }\n\n if (!code_verifier) {\n event.res.status = 400;\n return {\n error: \"invalid_request\",\n error_description: \"Missing code_verifier (PKCE required)\",\n };\n }\n\n try {\n // Decode the authorization code\n const payload = decodeAuthCode(code);\n\n // Validate PKCE: SHA256(code_verifier) must equal code_challenge\n if (payload.codeChallenge) {\n const expectedChallenge = createS256Challenge(code_verifier);\n if (expectedChallenge !== payload.codeChallenge) {\n event.res.status = 400;\n return {\n error: \"invalid_grant\",\n error_description: \"Invalid code_verifier\",\n };\n }\n }\n\n // Create access token with embedded credentials when organizationSlug is present.\n const accessToken = createAccessToken(payload.apiToken, payload.organizationSlug);\n\n // Create refresh token (encrypted credentials, longer expiry)\n const refreshToken = createAuthCode(\n { apiToken: payload.apiToken, organizationSlug: payload.organizationSlug },\n 86400 * 30, // 30 days\n );\n\n return {\n access_token: accessToken,\n token_type: \"Bearer\",\n expires_in: 3600, // 1 hour\n refresh_token: refreshToken,\n };\n } catch (error) {\n event.res.status = 400;\n return {\n error: \"invalid_grant\",\n error_description: error instanceof Error ? error.message : \"Invalid authorization code\",\n };\n }\n});\n\n/**\n * Handle refresh token grant\n */\nfunction handleRefreshToken(event: H3Event, refreshToken: string | undefined) {\n if (!refreshToken) {\n event.res.status = 400;\n return {\n error: \"invalid_request\",\n error_description: \"Missing refresh_token\",\n };\n }\n\n try {\n // Decode refresh token (it's just an encrypted auth code with longer expiry)\n const payload = decodeAuthCode(refreshToken);\n\n // Create new access token\n const accessToken = createAccessToken(payload.apiToken, payload.organizationSlug);\n\n // Create new refresh token (rotate for security)\n const newRefreshToken = createAuthCode(\n { apiToken: payload.apiToken, organizationSlug: payload.organizationSlug },\n 86400 * 30, // 30 days\n );\n\n return {\n access_token: accessToken,\n token_type: \"Bearer\",\n expires_in: 3600,\n refresh_token: newRefreshToken,\n };\n } catch (error) {\n event.res.status = 400;\n return {\n error: \"invalid_grant\",\n error_description: error instanceof Error ? error.message : \"Invalid refresh token\",\n };\n }\n}\n\n/**\n * Create S256 PKCE challenge from verifier\n * SHA256(code_verifier) encoded as base64url\n */\nexport function createS256Challenge(codeVerifier: string): string {\n return createHash(\"sha256\").update(codeVerifier).digest(\"base64url\");\n}\n\n/**\n * Escape HTML special characters\n */\nfunction escapeHtml(str: string): string {\n return str\n .replaceAll(\"&\", \"&\")\n .replaceAll(\"<\", \"<\")\n .replaceAll(\">\", \">\")\n .replaceAll('\"', \""\")\n .replaceAll(\"'\", \"'\");\n}\n\n/**\n * Render the login form HTML\n */\nfunction renderLoginForm(params: {\n redirectUri?: string;\n state?: string;\n codeChallenge?: string;\n codeChallengeMethod?: string;\n organizationSlug?: string;\n error?: string;\n}): string {\n const { redirectUri, state, codeChallenge, codeChallengeMethod, organizationSlug, error } =\n params;\n\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Connect to Laravel Forge</title>\n <style>\n * { box-sizing: border-box; margin: 0; padding: 0; }\n body {\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n background: linear-gradient(135deg, #18b69b 0%, #0e7460 100%);\n min-height: 100vh;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 20px;\n }\n .container {\n background: white;\n border-radius: 16px;\n box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);\n padding: 40px;\n width: 100%;\n max-width: 420px;\n }\n .logo {\n text-align: center;\n margin-bottom: 24px;\n }\n .logo svg { width: 48px; height: 48px; }\n h1 {\n text-align: center;\n color: #1a1a2e;\n font-size: 24px;\n margin-bottom: 8px;\n }\n .subtitle {\n text-align: center;\n color: #666;\n font-size: 14px;\n margin-bottom: 32px;\n }\n .error {\n background: #fee2e2;\n border: 1px solid #fecaca;\n color: #dc2626;\n padding: 12px 16px;\n border-radius: 8px;\n margin-bottom: 24px;\n font-size: 14px;\n }\n .notice {\n background: #f0fdf4;\n border: 1px solid #bbf7d0;\n color: #166534;\n padding: 12px 16px;\n border-radius: 8px;\n margin-bottom: 24px;\n font-size: 13px;\n line-height: 1.4;\n }\n .form-group { margin-bottom: 20px; }\n label {\n display: block;\n font-size: 14px;\n font-weight: 500;\n color: #374151;\n margin-bottom: 6px;\n }\n input {\n width: 100%;\n padding: 12px 16px;\n border: 1px solid #d1d5db;\n border-radius: 8px;\n font-size: 16px;\n transition: border-color 0.2s, box-shadow 0.2s;\n }\n input:focus {\n outline: none;\n border-color: #18b69b;\n box-shadow: 0 0 0 3px rgba(24, 182, 155, 0.2);\n }\n input::placeholder { color: #9ca3af; }\n .help-text {\n font-size: 12px;\n color: #6b7280;\n margin-top: 4px;\n }\n .help-text a { color: #18b69b; text-decoration: none; }\n .help-text a:hover { text-decoration: underline; }\n button {\n width: 100%;\n padding: 14px 24px;\n background: linear-gradient(135deg, #18b69b 0%, #0e7460 100%);\n color: white;\n border: none;\n border-radius: 8px;\n font-size: 16px;\n font-weight: 600;\n cursor: pointer;\n transition: transform 0.2s, box-shadow 0.2s;\n }\n button:hover {\n transform: translateY(-1px);\n box-shadow: 0 4px 12px rgba(24, 182, 155, 0.4);\n }\n button:active { transform: translateY(0); }\n .footer {\n text-align: center;\n margin-top: 24px;\n font-size: 12px;\n color: #9ca3af;\n }\n .footer a { color: #18b69b; text-decoration: none; }\n .footer a:hover { text-decoration: underline; }\n </style>\n</head>\n<body>\n <div class=\"container\">\n <div class=\"logo\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n <path d=\"M12 2L2 7L12 12L22 7L12 2Z\" stroke=\"#18b69b\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n <path d=\"M2 17L12 22L22 17\" stroke=\"#0e7460\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n <path d=\"M2 12L12 17L22 12\" stroke=\"#18b69b\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n </svg>\n </div>\n <h1>Connect to Laravel Forge</h1>\n <p class=\"subtitle\">Enter your Forge API token to connect with Claude</p>\n\n ${error ? `<div class=\"error\">${escapeHtml(error)}</div>` : \"\"}\n\n <div class=\"notice\">\n <strong>Your token is not stored on this server.</strong> It is encrypted into the authorization code and sent back to Claude Desktop, which holds it in its own secure storage.\n </div>\n\n <form method=\"POST\" action=\"/authorize\">\n <input type=\"hidden\" name=\"redirectUri\" value=\"${escapeHtml(redirectUri || \"\")}\">\n <input type=\"hidden\" name=\"state\" value=\"${escapeHtml(state || \"\")}\">\n <input type=\"hidden\" name=\"codeChallenge\" value=\"${escapeHtml(codeChallenge || \"\")}\">\n <input type=\"hidden\" name=\"codeChallengeMethod\" value=\"${escapeHtml(codeChallengeMethod || \"S256\")}\">\n\n <div class=\"form-group\">\n <label for=\"apiToken\">Forge API Token *</label>\n <input type=\"password\" id=\"apiToken\" name=\"apiToken\" required placeholder=\"Enter your Forge API token\">\n <p class=\"help-text\">\n Generate at <a href=\"https://forge.laravel.com/user-profile/api\" target=\"_blank\" rel=\"noopener\">forge.laravel.com → API Tokens</a>\n </p>\n </div>\n\n <div class=\"form-group\">\n <label for=\"organizationSlug\">Organization Slug</label>\n <input type=\"text\" id=\"organizationSlug\" name=\"organizationSlug\" value=\"${escapeHtml(organizationSlug || \"\")}\" placeholder=\"e.g. studio-meta\" autocapitalize=\"off\" autocorrect=\"off\" spellcheck=\"false\">\n <p class=\"help-text\">Optional, but recommended as the default org for Claude MCP requests.</p>\n </div>\n\n <button type=\"submit\">Connect to Forge</button>\n </form>\n\n <p class=\"footer\">\n Powered by <a href=\"https://github.com/studiometa/forge-tools\" target=\"_blank\" rel=\"noopener\">forge-tools</a>\n </p>\n </div>\n</body>\n</html>`;\n}\n\n/**\n * Render success page with auto-redirect\n */\nfunction renderSuccessPage(redirectUrl: string): string {\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <meta http-equiv=\"refresh\" content=\"2;url=${escapeHtml(redirectUrl)}\">\n <title>Connected — Forge MCP</title>\n <style>\n * { box-sizing: border-box; margin: 0; padding: 0; }\n body {\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n background: linear-gradient(135deg, #18b69b 0%, #0e7460 100%);\n min-height: 100vh;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 20px;\n }\n .container {\n background: white;\n border-radius: 16px;\n box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);\n padding: 40px;\n width: 100%;\n max-width: 420px;\n text-align: center;\n }\n .success-icon {\n width: 64px;\n height: 64px;\n margin: 0 auto 24px;\n background: linear-gradient(135deg, #10b981 0%, #059669 100%);\n border-radius: 50%;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n .success-icon svg { width: 32px; height: 32px; stroke: white; }\n h1 { color: #1a1a2e; font-size: 24px; margin-bottom: 8px; }\n .message { color: #666; font-size: 14px; margin-bottom: 24px; }\n .spinner {\n width: 24px;\n height: 24px;\n border: 3px solid #e5e7eb;\n border-top-color: #18b69b;\n border-radius: 50%;\n animation: spin 1s linear infinite;\n margin: 0 auto 16px;\n }\n @keyframes spin { to { transform: rotate(360deg); } }\n .redirect-text { color: #9ca3af; font-size: 13px; margin-bottom: 16px; }\n .manual-link { color: #18b69b; text-decoration: none; font-size: 14px; }\n .manual-link:hover { text-decoration: underline; }\n </style>\n</head>\n<body>\n <div class=\"container\">\n <div class=\"success-icon\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n <path d=\"M20 6L9 17L4 12\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n </svg>\n </div>\n <h1>Successfully Connected!</h1>\n <p class=\"message\">Your Forge API token has been encrypted and transmitted securely.</p>\n <div class=\"spinner\"></div>\n <p class=\"redirect-text\">Redirecting to Claude Desktop...</p>\n <a href=\"${escapeHtml(redirectUrl)}\" class=\"manual-link\">Click here if not redirected automatically</a>\n </div>\n <script>\n setTimeout(function() {\n window.location.href = ${JSON.stringify(redirectUrl)};\n }, 2000);\n </script>\n</body>\n</html>`;\n}\n\n/**\n * Render error page\n */\nfunction renderErrorPage(message: string): string {\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Error — Forge MCP</title>\n <style>\n body {\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n background: #f3f4f6;\n min-height: 100vh;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 20px;\n }\n .container {\n background: white;\n border-radius: 16px;\n box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);\n padding: 40px;\n text-align: center;\n max-width: 400px;\n }\n h1 { color: #dc2626; margin-bottom: 16px; }\n p { color: #6b7280; }\n </style>\n</head>\n<body>\n <div class=\"container\">\n <h1>Error</h1>\n <p>${escapeHtml(message)}</p>\n </div>\n</body>\n</html>`;\n}\n"],"mappings":"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"}
|
package/dist/server.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { t as parseReadOnlyFlag } from "./flags-
|
|
3
|
-
import { t as VERSION } from "./version-
|
|
4
|
-
import { a as SessionManager, n as createMcpRequestHandler, t as createHealthApp } from "./http-
|
|
2
|
+
import { t as parseReadOnlyFlag } from "./flags-CIh56cQ7.js";
|
|
3
|
+
import { t as VERSION } from "./version-BdjPN_IX.js";
|
|
4
|
+
import { a as SessionManager, n as createMcpRequestHandler, t as createHealthApp } from "./http-DVoeL5Wa.js";
|
|
5
5
|
import { toNodeHandler } from "h3/node";
|
|
6
6
|
import { createServer } from "node:http";
|
|
7
|
+
//#region src/server.ts
|
|
7
8
|
/**
|
|
8
9
|
* Forge MCP Server - HTTP Transport (Streamable HTTP)
|
|
9
10
|
*
|
|
@@ -63,8 +64,7 @@ function startHttpServer(port = DEFAULT_PORT, host = DEFAULT_HOST, options) {
|
|
|
63
64
|
});
|
|
64
65
|
server.listen(port, host, () => {
|
|
65
66
|
const displayHost = host === "0.0.0.0" ? "localhost" : host;
|
|
66
|
-
|
|
67
|
-
console.log(`Forge MCP server v${VERSION}${mode}`);
|
|
67
|
+
console.log(`Forge MCP server v${VERSION}${readOnly ? " (read-only)" : ""}`);
|
|
68
68
|
console.log(`Node.js ${process.version}`);
|
|
69
69
|
console.log("");
|
|
70
70
|
console.log(`Running at http://${displayHost}:${port}`);
|
|
@@ -95,6 +95,7 @@ if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith
|
|
|
95
95
|
console.error("Fatal error:", error);
|
|
96
96
|
process.exit(1);
|
|
97
97
|
});
|
|
98
|
+
//#endregion
|
|
98
99
|
export { startHttpServer };
|
|
99
100
|
|
|
100
101
|
//# sourceMappingURL=server.js.map
|