@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.
@@ -0,0 +1,50 @@
1
+ import { registryGet } from "../api.js"
2
+ import { CACHE_TTL } from "../constants.js"
3
+ import { errorResponse, parseName, textResponse } from "../format.js"
4
+
5
+ /**
6
+ * Fetch the quality report for an LPM package.
7
+ *
8
+ * @param {{ name: string }} params
9
+ * @param {{ cache: import('../cache.js').MemoryCache, getToken: () => Promise<string|null>, getBaseUrl: () => string }} ctx
10
+ */
11
+ export async function qualityReport({ name }, ctx) {
12
+ let queryName
13
+ try {
14
+ const parsed = parseName(name)
15
+ queryName = `${parsed.owner}.${parsed.name}`
16
+ } catch (err) {
17
+ return errorResponse(err.message)
18
+ }
19
+
20
+ const token = await ctx.getToken()
21
+ const cacheKey = `quality:${queryName}:auth=${!!token}`
22
+
23
+ const cached = ctx.cache.get(cacheKey)
24
+ if (cached) return cached
25
+
26
+ const baseUrl = ctx.getBaseUrl()
27
+ const result = await registryGet(
28
+ `/quality?name=${encodeURIComponent(queryName)}`,
29
+ token,
30
+ baseUrl,
31
+ )
32
+
33
+ if (!result.ok) {
34
+ if (result.status === 404) {
35
+ return errorResponse(`Package ${queryName} not found.`)
36
+ }
37
+ if (result.status === 403) {
38
+ return errorResponse(
39
+ "Access denied. Private packages require authentication.",
40
+ )
41
+ }
42
+ return errorResponse(
43
+ result.data?.error || `Request failed (${result.status})`,
44
+ )
45
+ }
46
+
47
+ const response = textResponse(JSON.stringify(result.data, null, 2))
48
+ ctx.cache.set(cacheKey, response, CACHE_TTL.SHORT)
49
+ return response
50
+ }
@@ -0,0 +1,58 @@
1
+ import { searchGet } from "../api.js"
2
+ import { CACHE_TTL } from "../constants.js"
3
+ import { errorResponse, textResponse } from "../format.js"
4
+
5
+ /**
6
+ * Search for users or organizations on the LPM registry.
7
+ *
8
+ * @param {{ query: string, limit?: number }} params
9
+ * @param {{ cache: import('../cache.js').MemoryCache, getToken: () => Promise<string|null>, getBaseUrl: () => string }} ctx
10
+ */
11
+ export async function searchOwners({ query, limit }, ctx) {
12
+ if (!query || query.trim().length < 1) {
13
+ return errorResponse("Search query is required.")
14
+ }
15
+
16
+ const q = query.trim()
17
+ const safeLimit = Math.min(Math.max(limit || 5, 1), 10)
18
+
19
+ const cacheKey = `search-owners:${q}:${safeLimit}`
20
+ const cached = ctx.cache.get(cacheKey)
21
+ if (cached) return cached
22
+
23
+ const token = await ctx.getToken()
24
+ const baseUrl = ctx.getBaseUrl()
25
+ const params = new URLSearchParams({ q, limit: String(safeLimit) })
26
+
27
+ const result = await searchGet(`/owners?${params.toString()}`, token, baseUrl)
28
+
29
+ if (!result.ok) {
30
+ return errorResponse(
31
+ result.data?.error || `Search failed (${result.status})`,
32
+ )
33
+ }
34
+
35
+ const owners = result.data?.owners || []
36
+
37
+ if (owners.length === 0) {
38
+ const response = textResponse(`No users or organizations found for "${q}".`)
39
+ ctx.cache.set(cacheKey, response, CACHE_TTL.SHORT)
40
+ return response
41
+ }
42
+
43
+ const lines = [
44
+ `Found ${owners.length} profile${owners.length === 1 ? "" : "s"}:\n`,
45
+ ]
46
+
47
+ for (const owner of owners) {
48
+ const type = owner.type === "org" ? "org" : "user"
49
+ const name =
50
+ owner.name && owner.name !== owner.slug ? ` (${owner.name})` : ""
51
+ const bio = owner.bio ? ` — ${owner.bio.substring(0, 80)}` : ""
52
+ lines.push(`- @${owner.slug}${name} [${type}]${bio}`)
53
+ }
54
+
55
+ const response = textResponse(lines.join("\n"))
56
+ ctx.cache.set(cacheKey, response, CACHE_TTL.SHORT)
57
+ return response
58
+ }
@@ -0,0 +1,232 @@
1
+ import { searchGet } from "../api.js"
2
+ import { CACHE_TTL, ERROR_MESSAGES } from "../constants.js"
3
+ import { errorResponse, textResponse } from "../format.js"
4
+
5
+ /**
6
+ * Unified LPM package search.
7
+ *
8
+ * Uses dual-endpoint strategy:
9
+ * - Semantic path (/packages?mode=semantic): when only query + ecosystem/limit are provided
10
+ * - Explore path (/packages/explore): when any structured filter is active
11
+ *
12
+ * @param {object} params
13
+ * @param {string} [params.query] - Search text (required unless category provided)
14
+ * @param {string} [params.category] - Category slug filter
15
+ * @param {string} [params.ecosystem] - Ecosystem filter (js, swift, xcframework)
16
+ * @param {string} [params.distribution] - Distribution mode filter (marketplace, pool, private)
17
+ * @param {string} [params.packageType] - Package type filter
18
+ * @param {string} [params.sort] - Sort order (newest, popular, name)
19
+ * @param {boolean} [params.hasTypes] - TypeScript types filter
20
+ * @param {string} [params.moduleType] - Module type filter (esm, cjs, dual)
21
+ * @param {string} [params.license] - License filter
22
+ * @param {string} [params.minNodeVersion] - Min Node version filter
23
+ * @param {number} [params.limit] - Max results (1-50, default 10)
24
+ * @param {{ cache: import('../cache.js').MemoryCache, getToken: () => Promise<string|null>, getBaseUrl: () => string }} ctx
25
+ */
26
+ export async function search(params, ctx) {
27
+ const {
28
+ query,
29
+ category,
30
+ distribution,
31
+ packageType,
32
+ sort,
33
+ hasTypes,
34
+ moduleType,
35
+ license,
36
+ minNodeVersion,
37
+ } = params
38
+
39
+ // Validation: at least query or category required
40
+ if (!query?.trim() && !category) {
41
+ return errorResponse(ERROR_MESSAGES.searchNoParams)
42
+ }
43
+ if (query && query.trim().length < 2) {
44
+ return errorResponse("Search query must be at least 2 characters.")
45
+ }
46
+
47
+ const token = await ctx.getToken()
48
+ const baseUrl = ctx.getBaseUrl()
49
+
50
+ // Determine which path to use
51
+ const hasStructuredFilters =
52
+ category ||
53
+ distribution ||
54
+ packageType ||
55
+ sort ||
56
+ hasTypes ||
57
+ moduleType ||
58
+ license ||
59
+ minNodeVersion
60
+
61
+ // Build deterministic cache key from all params
62
+ const cacheKey = buildCacheKey(params)
63
+ const cached = ctx.cache.get(cacheKey)
64
+ if (cached) return cached
65
+
66
+ let response
67
+ if (hasStructuredFilters) {
68
+ response = await exploreSearch(params, token, baseUrl)
69
+ } else {
70
+ response = await semanticSearch(params, token, baseUrl)
71
+ }
72
+
73
+ if (response.isError) return response
74
+
75
+ ctx.cache.set(cacheKey, response, CACHE_TTL.SHORT)
76
+ return response
77
+ }
78
+
79
+ /**
80
+ * Semantic search path — best quality for natural language queries.
81
+ * Uses /api/search/packages?mode=semantic
82
+ */
83
+ async function semanticSearch({ query, ecosystem, limit }, token, baseUrl) {
84
+ const q = query.trim()
85
+ const safeLimit = Math.min(Math.max(limit || 10, 1), 20)
86
+
87
+ const searchParams = new URLSearchParams({
88
+ q,
89
+ mode: "semantic",
90
+ limit: String(safeLimit),
91
+ })
92
+ if (ecosystem) searchParams.set("ecosystem", ecosystem)
93
+
94
+ const result = await searchGet(
95
+ `/packages?${searchParams.toString()}`,
96
+ token,
97
+ baseUrl,
98
+ )
99
+
100
+ if (!result.ok) {
101
+ return errorResponse(
102
+ result.data?.error || `Search failed (${result.status})`,
103
+ )
104
+ }
105
+
106
+ const packages = result.data?.packages || []
107
+ return formatResults(packages, query)
108
+ }
109
+
110
+ /**
111
+ * Explore search path — supports all structured filters.
112
+ * Uses /api/search/packages/explore
113
+ */
114
+ async function exploreSearch(params, token, baseUrl) {
115
+ const {
116
+ query,
117
+ category,
118
+ ecosystem,
119
+ distribution,
120
+ packageType,
121
+ sort,
122
+ hasTypes,
123
+ moduleType,
124
+ license,
125
+ minNodeVersion,
126
+ limit,
127
+ } = params
128
+
129
+ const safeLimit = Math.min(Math.max(limit || 10, 1), 50)
130
+
131
+ const searchParams = new URLSearchParams()
132
+ if (query?.trim()) searchParams.set("q", query.trim())
133
+ if (category) searchParams.set("category", category)
134
+ if (ecosystem) searchParams.set("ecosystem", ecosystem)
135
+ if (distribution) searchParams.set("distribution", distribution)
136
+ if (packageType) searchParams.set("packageType", packageType)
137
+ if (sort) searchParams.set("sort", sort)
138
+ if (hasTypes) searchParams.set("hasTypes", "true")
139
+ if (moduleType) searchParams.set("moduleType", moduleType)
140
+ if (license) searchParams.set("license", license)
141
+ if (minNodeVersion) searchParams.set("minNodeVersion", minNodeVersion)
142
+ searchParams.set("limit", String(safeLimit))
143
+
144
+ const result = await searchGet(
145
+ `/packages/explore?${searchParams.toString()}`,
146
+ token,
147
+ baseUrl,
148
+ )
149
+
150
+ if (!result.ok) {
151
+ return errorResponse(
152
+ result.data?.error || `Search failed (${result.status})`,
153
+ )
154
+ }
155
+
156
+ const packages = result.data?.packages || []
157
+ return formatResults(packages, query)
158
+ }
159
+
160
+ /**
161
+ * Format search results as readable text for LLM consumption.
162
+ * Handles both semantic and explore response shapes.
163
+ */
164
+ function formatResults(packages, query) {
165
+ if (packages.length === 0) {
166
+ const label = query?.trim() ? ` for "${query.trim()}"` : ""
167
+ return textResponse(`No packages found${label}.`)
168
+ }
169
+
170
+ const lines = [
171
+ `Found ${packages.length} package${packages.length === 1 ? "" : "s"}:\n`,
172
+ ]
173
+
174
+ for (const pkg of packages) {
175
+ const owner = pkg.ownerSlug || pkg.owner
176
+ const name = `${owner}.${pkg.name}`
177
+
178
+ // Type badge (skip default "package")
179
+ const typeLabel =
180
+ pkg.packageType && pkg.packageType !== "package"
181
+ ? ` [${pkg.packageType}]`
182
+ : ""
183
+
184
+ // Ecosystem badge (skip default "js")
185
+ const ecosystemLabel =
186
+ pkg.ecosystem && pkg.ecosystem !== "js" ? ` {${pkg.ecosystem}}` : ""
187
+
188
+ // Distribution mode
189
+ const distLabel = pkg.distributionMode ? ` (${pkg.distributionMode})` : ""
190
+
191
+ const desc = pkg.description ? ` — ${pkg.description}` : ""
192
+ const downloads = pkg.downloadCount
193
+ ? ` (${Number(pkg.downloadCount).toLocaleString()} downloads)`
194
+ : ""
195
+
196
+ lines.push(
197
+ `- ${name}${typeLabel}${ecosystemLabel}${distLabel}${desc}${downloads}`,
198
+ )
199
+
200
+ // Second line with extra metadata (from explore path)
201
+ const meta = []
202
+ if (pkg.qualityScore != null) meta.push(`Quality: ${pkg.qualityScore}`)
203
+ if (pkg.category) meta.push(`Category: ${pkg.category}`)
204
+ if (pkg.tags?.length > 0) meta.push(`Tags: ${pkg.tags.join(", ")}`)
205
+ if (meta.length > 0) {
206
+ lines.push(` ${meta.join(" | ")}`)
207
+ }
208
+ }
209
+
210
+ return textResponse(lines.join("\n"))
211
+ }
212
+
213
+ /**
214
+ * Build a deterministic cache key from all search params.
215
+ */
216
+ function buildCacheKey(params) {
217
+ const parts = [
218
+ "search",
219
+ params.query?.trim() || "",
220
+ params.category || "",
221
+ params.ecosystem || "",
222
+ params.distribution || "",
223
+ params.packageType || "",
224
+ params.sort || "",
225
+ params.hasTypes ? "types" : "",
226
+ params.moduleType || "",
227
+ params.license || "",
228
+ params.minNodeVersion || "",
229
+ String(params.limit || 10),
230
+ ]
231
+ return parts.join(":")
232
+ }
@@ -0,0 +1,38 @@
1
+ import { registryGet } from "../api.js"
2
+ import { CACHE_TTL, ERROR_MESSAGES } from "../constants.js"
3
+ import { errorResponse, textResponse } from "../format.js"
4
+
5
+ /**
6
+ * Fetch info about the authenticated LPM user.
7
+ * Requires authentication.
8
+ *
9
+ * @param {object} _params
10
+ * @param {{ cache: import('../cache.js').MemoryCache, getToken: () => Promise<string|null>, getBaseUrl: () => string }} ctx
11
+ */
12
+ export async function userInfo(_params, ctx) {
13
+ const token = await ctx.getToken()
14
+ if (!token) {
15
+ return errorResponse(ERROR_MESSAGES.noToken)
16
+ }
17
+
18
+ const cacheKey = "user-info"
19
+
20
+ const cached = ctx.cache.get(cacheKey)
21
+ if (cached) return cached
22
+
23
+ const baseUrl = ctx.getBaseUrl()
24
+ const result = await registryGet("/-/whoami", token, baseUrl)
25
+
26
+ if (!result.ok) {
27
+ if (result.status === 401) {
28
+ return errorResponse(ERROR_MESSAGES.unauthorized)
29
+ }
30
+ return errorResponse(
31
+ result.data?.error || `Request failed (${result.status})`,
32
+ )
33
+ }
34
+
35
+ const response = textResponse(JSON.stringify(result.data, null, 2))
36
+ ctx.cache.set(cacheKey, response, CACHE_TTL.SHORT)
37
+ return response
38
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@lpm-registry/mcp-server",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for the LPM package registry — exposes package info, quality reports, name checks, pool stats, and marketplace search as AI tools",
5
+ "author": "LPM <hello@lpm.dev> (https://lpm.dev)",
6
+ "homepage": "https://lpm.dev",
7
+ "license": "ISC",
8
+ "keywords": [
9
+ "lpm",
10
+ "mcp",
11
+ "model-context-protocol",
12
+ "package-registry",
13
+ "ai-tools"
14
+ ],
15
+ "type": "module",
16
+ "main": "lib/server.js",
17
+ "bin": {
18
+ "lpm-mcp-server": "./bin/mcp-server.js"
19
+ },
20
+ "exports": {
21
+ ".": "./lib/server.js"
22
+ },
23
+ "files": [
24
+ "bin",
25
+ "lib",
26
+ "!lib/__tests__",
27
+ "README.md"
28
+ ],
29
+ "scripts": {
30
+ "prepare": "husky",
31
+ "start": "node bin/mcp-server.js",
32
+ "test": "vitest run",
33
+ "test:watch": "vitest",
34
+ "lint": "biome check .",
35
+ "lint:fix": "biome check --write ."
36
+ },
37
+ "dependencies": {
38
+ "@modelcontextprotocol/sdk": "^1.12.0",
39
+ "zod": "^3.24.0"
40
+ },
41
+ "optionalDependencies": {
42
+ "keytar": "^7.9.0"
43
+ },
44
+ "devDependencies": {
45
+ "@biomejs/biome": "^2.3.10",
46
+ "husky": "^9.1.7",
47
+ "vitest": "^3.0.0"
48
+ },
49
+ "engines": {
50
+ "node": ">=18.0.0"
51
+ }
52
+ }