@lpm-registry/mcp-server 0.1.0

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/lib/api.js ADDED
@@ -0,0 +1,145 @@
1
+ // CRITICAL: Never use console.log() — it corrupts the MCP stdio transport
2
+
3
+ import {
4
+ MAX_RETRIES,
5
+ REQUEST_TIMEOUT_MS,
6
+ RETRY_BACKOFF_MULTIPLIER,
7
+ RETRY_BASE_DELAY_MS,
8
+ RETRY_MAX_DELAY_MS,
9
+ RETRYABLE_STATUS_CODES,
10
+ } from "./constants.js"
11
+
12
+ function sleep(ms) {
13
+ return new Promise(resolve => setTimeout(resolve, ms))
14
+ }
15
+
16
+ function getBackoffDelay(attempt) {
17
+ const delay = RETRY_BASE_DELAY_MS * RETRY_BACKOFF_MULTIPLIER ** attempt
18
+ const jitter = delay * 0.1 * (Math.random() * 2 - 1)
19
+ return Math.min(delay + jitter, RETRY_MAX_DELAY_MS)
20
+ }
21
+
22
+ function parseRetryAfter(retryAfter) {
23
+ if (!retryAfter) return RETRY_BASE_DELAY_MS
24
+
25
+ const seconds = parseInt(retryAfter, 10)
26
+ if (!Number.isNaN(seconds)) return seconds * 1000
27
+
28
+ const date = new Date(retryAfter)
29
+ if (!Number.isNaN(date.getTime())) {
30
+ return Math.max(0, date.getTime() - Date.now())
31
+ }
32
+
33
+ return RETRY_BASE_DELAY_MS
34
+ }
35
+
36
+ /**
37
+ * Make a GET request with retry, timeout, and rate limit handling.
38
+ *
39
+ * @param {string} url - Full URL to fetch
40
+ * @param {string | null} token - Bearer token (null to skip auth header)
41
+ * @returns {Promise<{ok: boolean, status: number, data: *}>}
42
+ */
43
+ export async function apiGet(url, token) {
44
+ const headers = {}
45
+ if (token) {
46
+ headers.Authorization = `Bearer ${token}`
47
+ }
48
+
49
+ let lastError = null
50
+
51
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
52
+ const controller = new AbortController()
53
+ const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
54
+
55
+ try {
56
+ const response = await fetch(url, {
57
+ method: "GET",
58
+ headers,
59
+ signal: controller.signal,
60
+ })
61
+
62
+ clearTimeout(timeoutId)
63
+
64
+ // No retry on auth errors
65
+ if (response.status === 401 || response.status === 403) {
66
+ const data = await response.json().catch(() => ({}))
67
+ return { ok: false, status: response.status, data }
68
+ }
69
+
70
+ // Rate limiting
71
+ if (response.status === 429) {
72
+ const retryAfter = response.headers.get("Retry-After")
73
+ const delayMs = parseRetryAfter(retryAfter)
74
+
75
+ if (attempt < MAX_RETRIES) {
76
+ await sleep(delayMs)
77
+ continue
78
+ }
79
+
80
+ return { ok: false, status: 429, data: { error: "Rate limited" } }
81
+ }
82
+
83
+ // Retryable server errors
84
+ if (
85
+ RETRYABLE_STATUS_CODES.includes(response.status) &&
86
+ attempt < MAX_RETRIES
87
+ ) {
88
+ await sleep(getBackoffDelay(attempt))
89
+ continue
90
+ }
91
+
92
+ const data = await response.json().catch(() => ({}))
93
+ return { ok: response.ok, status: response.status, data }
94
+ } catch (error) {
95
+ clearTimeout(timeoutId)
96
+
97
+ lastError = error
98
+
99
+ if (attempt < MAX_RETRIES) {
100
+ await sleep(getBackoffDelay(attempt))
101
+ continue
102
+ }
103
+
104
+ if (error.name === "AbortError") {
105
+ return { ok: false, status: 0, data: { error: "Request timed out" } }
106
+ }
107
+
108
+ return {
109
+ ok: false,
110
+ status: 0,
111
+ data: { error: error.message || "Network error" },
112
+ }
113
+ }
114
+ }
115
+
116
+ return {
117
+ ok: false,
118
+ status: 0,
119
+ data: { error: lastError?.message || "Network error" },
120
+ }
121
+ }
122
+
123
+ /**
124
+ * GET from the registry API (/api/registry prefix).
125
+ *
126
+ * @param {string} path - Path after /api/registry (e.g., '/-/whoami')
127
+ * @param {string | null} token
128
+ * @param {string} baseUrl
129
+ * @returns {Promise<{ok: boolean, status: number, data: *}>}
130
+ */
131
+ export function registryGet(path, token, baseUrl) {
132
+ return apiGet(`${baseUrl}/api/registry${path}`, token)
133
+ }
134
+
135
+ /**
136
+ * GET from the search API (/api/search prefix).
137
+ *
138
+ * @param {string} path - Path after /api/search (e.g., '/packages?q=react')
139
+ * @param {string | null} token
140
+ * @param {string} baseUrl
141
+ * @returns {Promise<{ok: boolean, status: number, data: *}>}
142
+ */
143
+ export function searchGet(path, token, baseUrl) {
144
+ return apiGet(`${baseUrl}/api/search${path}`, token)
145
+ }
package/lib/auth.js ADDED
@@ -0,0 +1,42 @@
1
+ import {
2
+ DEFAULT_REGISTRY_URL,
3
+ KEYTAR_ACCOUNT_NAME,
4
+ KEYTAR_SERVICE_NAME,
5
+ } from "./constants.js"
6
+
7
+ /**
8
+ * Resolve the LPM auth token.
9
+ * Priority: LPM_TOKEN env var → OS keychain (via keytar) → null
10
+ *
11
+ * @returns {Promise<string | null>}
12
+ */
13
+ export async function getToken() {
14
+ // 1. Environment variable (recommended for MCP configs)
15
+ if (process.env.LPM_TOKEN) {
16
+ return process.env.LPM_TOKEN
17
+ }
18
+
19
+ // 2. OS keychain (reads from same store as `lpm login`)
20
+ try {
21
+ const keytar = await import("keytar")
22
+ const token = await keytar.default.getPassword(
23
+ KEYTAR_SERVICE_NAME,
24
+ KEYTAR_ACCOUNT_NAME,
25
+ )
26
+ if (token) return token
27
+ } catch {
28
+ // keytar not available — this is expected in many environments
29
+ }
30
+
31
+ return null
32
+ }
33
+
34
+ /**
35
+ * Resolve the LPM registry base URL.
36
+ * Priority: LPM_REGISTRY_URL env var → default
37
+ *
38
+ * @returns {string}
39
+ */
40
+ export function getBaseUrl() {
41
+ return process.env.LPM_REGISTRY_URL || DEFAULT_REGISTRY_URL
42
+ }
package/lib/cache.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Simple in-memory cache with TTL support.
3
+ * Used to reduce API calls for repeated queries within an MCP session.
4
+ */
5
+ export class MemoryCache {
6
+ constructor() {
7
+ this._store = new Map()
8
+ }
9
+
10
+ /**
11
+ * Get a cached value. Returns undefined if missing or expired.
12
+ * @param {string} key
13
+ * @returns {*}
14
+ */
15
+ get(key) {
16
+ const entry = this._store.get(key)
17
+ if (!entry) return undefined
18
+
19
+ if (Date.now() > entry.expiresAt) {
20
+ this._store.delete(key)
21
+ return undefined
22
+ }
23
+
24
+ return entry.value
25
+ }
26
+
27
+ /**
28
+ * Store a value with a TTL. If ttlMs is 0 or falsy, this is a no-op.
29
+ * @param {string} key
30
+ * @param {*} value
31
+ * @param {number} ttlMs
32
+ */
33
+ set(key, value, ttlMs) {
34
+ if (!ttlMs) return
35
+
36
+ this._store.set(key, {
37
+ value,
38
+ expiresAt: Date.now() + ttlMs,
39
+ })
40
+ }
41
+
42
+ /**
43
+ * Check if a valid (non-expired) entry exists.
44
+ * @param {string} key
45
+ * @returns {boolean}
46
+ */
47
+ has(key) {
48
+ return this.get(key) !== undefined
49
+ }
50
+
51
+ /**
52
+ * Remove a specific entry.
53
+ * @param {string} key
54
+ */
55
+ delete(key) {
56
+ this._store.delete(key)
57
+ }
58
+
59
+ /**
60
+ * Remove all entries.
61
+ */
62
+ clear() {
63
+ this._store.clear()
64
+ }
65
+
66
+ /**
67
+ * Number of entries (including potentially expired ones).
68
+ * @returns {number}
69
+ */
70
+ get size() {
71
+ return this._store.size
72
+ }
73
+ }
package/lib/cli.js ADDED
@@ -0,0 +1,99 @@
1
+ import { execFile, execSync } from "node:child_process"
2
+
3
+ const CLI_TIMEOUT_MS = 60_000
4
+
5
+ /**
6
+ * Resolve the path to the `lpm` CLI binary.
7
+ *
8
+ * Resolution order:
9
+ * 1. LPM_CLI_PATH env var (explicit override)
10
+ * 2. `which lpm` (global install)
11
+ * 3. null (caller should handle fallback)
12
+ *
13
+ * @returns {string|null}
14
+ */
15
+ export function resolveCli() {
16
+ // 1. Explicit override
17
+ const envPath = process.env.LPM_CLI_PATH
18
+ if (envPath) return envPath
19
+
20
+ // 2. Global install
21
+ try {
22
+ const result = execSync("which lpm", {
23
+ encoding: "utf-8",
24
+ timeout: 5_000,
25
+ }).trim()
26
+ if (result) return result
27
+ } catch {
28
+ // not found
29
+ }
30
+
31
+ return null
32
+ }
33
+
34
+ /**
35
+ * Run an LPM CLI command and return parsed JSON output.
36
+ *
37
+ * @param {string[]} args - CLI arguments (e.g., ['add', '@lpm.dev/owner.pkg', '--json'])
38
+ * @param {{ timeout?: number }} options
39
+ * @returns {Promise<{ success: boolean, data: object|null, error: string|null }>}
40
+ */
41
+ export async function runCli(args, options = {}) {
42
+ const cliPath = resolveCli()
43
+
44
+ if (!cliPath) {
45
+ return {
46
+ success: false,
47
+ data: null,
48
+ error:
49
+ "LPM CLI not found. Install it with: npm install -g @lpm-registry/cli",
50
+ }
51
+ }
52
+
53
+ const timeout = options.timeout || CLI_TIMEOUT_MS
54
+
55
+ return new Promise(resolve => {
56
+ const _child = execFile(
57
+ cliPath,
58
+ args,
59
+ {
60
+ timeout,
61
+ maxBuffer: 10 * 1024 * 1024,
62
+ env: { ...process.env },
63
+ },
64
+ (err, stdout, stderr) => {
65
+ if (err && !stdout) {
66
+ // CLI failed without producing output
67
+ const message = err.killed
68
+ ? `CLI command timed out after ${timeout / 1000}s`
69
+ : err.message || "CLI command failed"
70
+ resolve({ success: false, data: null, error: message })
71
+ return
72
+ }
73
+
74
+ // Try to parse JSON from stdout
75
+ try {
76
+ const data = JSON.parse(stdout)
77
+ resolve({
78
+ success: data.success !== false,
79
+ data,
80
+ error:
81
+ data.success === false
82
+ ? data.errors?.[0] || "Command failed"
83
+ : null,
84
+ })
85
+ } catch {
86
+ // CLI produced output but not valid JSON
87
+ resolve({
88
+ success: false,
89
+ data: null,
90
+ error:
91
+ stderr?.trim() ||
92
+ stdout?.trim() ||
93
+ "CLI returned non-JSON output",
94
+ })
95
+ }
96
+ },
97
+ )
98
+ })
99
+ }
@@ -0,0 +1,35 @@
1
+ // CRITICAL: Never use console.log() in this package — it corrupts the MCP stdio transport
2
+
3
+ export const DEFAULT_REGISTRY_URL = "https://lpm.dev"
4
+
5
+ export const KEYTAR_SERVICE_NAME = "lpm-cli"
6
+ export const KEYTAR_ACCOUNT_NAME = "auth-token"
7
+
8
+ export const REQUEST_TIMEOUT_MS = 30_000
9
+ export const MAX_RETRIES = 2
10
+ export const RETRY_BASE_DELAY_MS = 1_000
11
+ export const RETRY_MAX_DELAY_MS = 10_000
12
+ export const RETRY_BACKOFF_MULTIPLIER = 2
13
+
14
+ export const RETRYABLE_STATUS_CODES = [408, 429, 500, 502, 503, 504]
15
+
16
+ export const CACHE_TTL = {
17
+ SHORT: 5 * 60 * 1000,
18
+ LONG: 60 * 60 * 1000,
19
+ NONE: 0,
20
+ }
21
+
22
+ export const ERROR_MESSAGES = {
23
+ noToken:
24
+ "No LPM token found. Set LPM_TOKEN environment variable or run `lpm login` first.",
25
+ unauthorized:
26
+ "Authentication failed. Your token may be expired or revoked. Run `lpm login` to re-authenticate.",
27
+ notFound: "Package not found. Check the name format: owner.package-name",
28
+ accessDenied: "Access denied. This package may be private.",
29
+ rateLimited: "Rate limited. Please wait and try again.",
30
+ networkError: "Cannot reach lpm.dev. Check your internet connection.",
31
+ timeout: "Request timed out. Try again later.",
32
+ invalidName:
33
+ "Invalid package name format. Expected: owner.package-name or @lpm.dev/owner.package-name",
34
+ searchNoParams: 'At least one of "query" or "category" is required.',
35
+ }
package/lib/format.js ADDED
@@ -0,0 +1,67 @@
1
+ import { ERROR_MESSAGES } from "./constants.js"
2
+
3
+ /**
4
+ * Create a successful MCP tool response.
5
+ * @param {string} text
6
+ * @returns {{ content: Array<{ type: string, text: string }> }}
7
+ */
8
+ export function textResponse(text) {
9
+ return {
10
+ content: [{ type: "text", text }],
11
+ }
12
+ }
13
+
14
+ /**
15
+ * Create an error MCP tool response.
16
+ * @param {string} message
17
+ * @returns {{ content: Array<{ type: string, text: string }>, isError: true }}
18
+ */
19
+ export function errorResponse(message) {
20
+ return {
21
+ content: [{ type: "text", text: message }],
22
+ isError: true,
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Create a successful MCP tool response with JSON data.
28
+ * @param {*} data
29
+ * @returns {{ content: Array<{ type: string, text: string }> }}
30
+ */
31
+ export function jsonResponse(data) {
32
+ return textResponse(JSON.stringify(data, null, 2))
33
+ }
34
+
35
+ /**
36
+ * Parse a package name in 'owner.pkg' or '@lpm.dev/owner.pkg' format.
37
+ *
38
+ * @param {string} input
39
+ * @returns {{ owner: string, name: string }}
40
+ * @throws {Error} if the format is invalid
41
+ */
42
+ export function parseName(input) {
43
+ if (!input || typeof input !== "string") {
44
+ throw new Error(ERROR_MESSAGES.invalidName)
45
+ }
46
+
47
+ let cleaned = input.trim()
48
+
49
+ // Strip @lpm.dev/ prefix
50
+ if (cleaned.startsWith("@lpm.dev/")) {
51
+ cleaned = cleaned.slice(9)
52
+ }
53
+
54
+ const dotIndex = cleaned.indexOf(".")
55
+ if (dotIndex < 1 || dotIndex >= cleaned.length - 1) {
56
+ throw new Error(ERROR_MESSAGES.invalidName)
57
+ }
58
+
59
+ const owner = cleaned.substring(0, dotIndex)
60
+ const name = cleaned.substring(dotIndex + 1)
61
+
62
+ if (!owner || !name) {
63
+ throw new Error(ERROR_MESSAGES.invalidName)
64
+ }
65
+
66
+ return { owner, name }
67
+ }
@@ -0,0 +1,49 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { dirname, join } from "node:path"
3
+
4
+ /**
5
+ * Resolve the installed version of an LPM package from the local package.json.
6
+ * Walks up the directory tree to find the nearest package.json containing
7
+ * the requested package in dependencies or devDependencies.
8
+ *
9
+ * @param {string} packageName - Package name (owner.name or @lpm.dev/owner.name)
10
+ * @param {string} [cwd] - Starting directory (defaults to process.cwd())
11
+ * @returns {string | null} - Resolved version string or null if not found
12
+ */
13
+ export function resolveInstalledVersion(packageName, cwd) {
14
+ // Normalize to @lpm.dev/ format for package.json lookup
15
+ const lpmName = packageName.startsWith("@lpm.dev/")
16
+ ? packageName
17
+ : `@lpm.dev/${packageName}`
18
+
19
+ let dir = cwd || process.cwd()
20
+ const _root = dirname(dir) === dir ? dir : undefined
21
+
22
+ // Walk up directory tree (max 10 levels to avoid infinite loops)
23
+ for (let i = 0; i < 10; i++) {
24
+ const pkgPath = join(dir, "package.json")
25
+ if (existsSync(pkgPath)) {
26
+ try {
27
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"))
28
+ const allDeps = {
29
+ ...pkg.dependencies,
30
+ ...pkg.devDependencies,
31
+ }
32
+
33
+ const version = allDeps[lpmName]
34
+ if (version) {
35
+ // Strip semver range chars (^, ~, >=, <=, >, <, =)
36
+ return version.replace(/^[\^~>=<]+/, "")
37
+ }
38
+ } catch {
39
+ // Invalid package.json, continue walking up
40
+ }
41
+ }
42
+
43
+ const parent = dirname(dir)
44
+ if (parent === dir) break // Reached filesystem root
45
+ dir = parent
46
+ }
47
+
48
+ return null
49
+ }