@jlcpcb/mcp 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,321 @@
1
+ /**
2
+ * S-Expression Parser for KiCad format
3
+ * Parses KiCad symbol and footprint S-expressions for browser-side rendering
4
+ */
5
+
6
+ export type SExpr = string | SExpr[]
7
+
8
+ /**
9
+ * Tokenize S-expression input into tokens
10
+ * Handles parentheses, quoted strings, and atoms
11
+ */
12
+ function tokenize(input: string): string[] {
13
+ const tokens: string[] = []
14
+ let i = 0
15
+
16
+ while (i < input.length) {
17
+ const char = input[i]
18
+
19
+ // Skip whitespace
20
+ if (/\s/.test(char)) {
21
+ i++
22
+ continue
23
+ }
24
+
25
+ // Opening/closing parens
26
+ if (char === '(' || char === ')') {
27
+ tokens.push(char)
28
+ i++
29
+ continue
30
+ }
31
+
32
+ // Quoted string
33
+ if (char === '"') {
34
+ let str = ''
35
+ i++ // skip opening quote
36
+ while (i < input.length) {
37
+ if (input[i] === '\\' && i + 1 < input.length) {
38
+ // Escape sequence
39
+ str += input[i + 1]
40
+ i += 2
41
+ } else if (input[i] === '"') {
42
+ i++ // skip closing quote
43
+ break
44
+ } else {
45
+ str += input[i]
46
+ i++
47
+ }
48
+ }
49
+ tokens.push(`"${str}"`)
50
+ continue
51
+ }
52
+
53
+ // Atom (unquoted token)
54
+ let atom = ''
55
+ while (i < input.length && !/[\s()]/.test(input[i])) {
56
+ atom += input[i]
57
+ i++
58
+ }
59
+ if (atom) {
60
+ tokens.push(atom)
61
+ }
62
+ }
63
+
64
+ return tokens
65
+ }
66
+
67
+ /**
68
+ * Parse tokens into nested S-expression structure
69
+ */
70
+ function parseTokens(tokens: string[]): { expr: SExpr; remaining: string[] } {
71
+ if (tokens.length === 0) {
72
+ return { expr: [], remaining: [] }
73
+ }
74
+
75
+ const token = tokens[0]
76
+
77
+ if (token === '(') {
78
+ // Start of list
79
+ const list: SExpr[] = []
80
+ let rest = tokens.slice(1)
81
+
82
+ while (rest.length > 0 && rest[0] !== ')') {
83
+ const { expr, remaining } = parseTokens(rest)
84
+ list.push(expr)
85
+ rest = remaining
86
+ }
87
+
88
+ // Skip closing paren
89
+ if (rest[0] === ')') {
90
+ rest = rest.slice(1)
91
+ }
92
+
93
+ return { expr: list, remaining: rest }
94
+ }
95
+
96
+ if (token === ')') {
97
+ // Unexpected closing paren - return empty and let caller handle
98
+ return { expr: [], remaining: tokens.slice(1) }
99
+ }
100
+
101
+ // Atom or quoted string
102
+ let value = token
103
+ // Strip quotes from quoted strings for easier access
104
+ if (value.startsWith('"') && value.endsWith('"')) {
105
+ value = value.slice(1, -1)
106
+ }
107
+
108
+ return { expr: value, remaining: tokens.slice(1) }
109
+ }
110
+
111
+ /**
112
+ * Parse S-expression string into nested structure
113
+ */
114
+ export function parseSExpr(input: string): SExpr {
115
+ const tokens = tokenize(input)
116
+ const { expr } = parseTokens(tokens)
117
+ return expr
118
+ }
119
+
120
+ /**
121
+ * Check if expression is a list
122
+ */
123
+ export function isList(expr: SExpr): expr is SExpr[] {
124
+ return Array.isArray(expr)
125
+ }
126
+
127
+ /**
128
+ * Check if expression is an atom (string)
129
+ */
130
+ export function isAtom(expr: SExpr): expr is string {
131
+ return typeof expr === 'string'
132
+ }
133
+
134
+ /**
135
+ * Get the first element of a list (the tag/type)
136
+ */
137
+ export function getTag(expr: SExpr): string | undefined {
138
+ if (isList(expr) && expr.length > 0 && isAtom(expr[0])) {
139
+ return expr[0]
140
+ }
141
+ return undefined
142
+ }
143
+
144
+ /**
145
+ * Find a child element by its tag name
146
+ * Returns first match or undefined
147
+ */
148
+ export function findChild(expr: SExpr, tag: string): SExpr[] | undefined {
149
+ if (!isList(expr)) return undefined
150
+
151
+ for (const child of expr) {
152
+ if (isList(child) && getTag(child) === tag) {
153
+ return child
154
+ }
155
+ }
156
+ return undefined
157
+ }
158
+
159
+ /**
160
+ * Find all child elements with a given tag
161
+ */
162
+ export function findChildren(expr: SExpr, tag: string): SExpr[][] {
163
+ if (!isList(expr)) return []
164
+
165
+ const results: SExpr[][] = []
166
+ for (const child of expr) {
167
+ if (isList(child) && getTag(child) === tag) {
168
+ results.push(child)
169
+ }
170
+ }
171
+ return results
172
+ }
173
+
174
+ /**
175
+ * Get a simple attribute value: (tag value) -> value
176
+ */
177
+ export function getAttr(expr: SExpr, tag: string): string | undefined {
178
+ const child = findChild(expr, tag)
179
+ if (child && child.length >= 2 && isAtom(child[1])) {
180
+ return child[1]
181
+ }
182
+ return undefined
183
+ }
184
+
185
+ /**
186
+ * Get a numeric attribute value
187
+ */
188
+ export function getNumericAttr(expr: SExpr, tag: string): number | undefined {
189
+ const value = getAttr(expr, tag)
190
+ if (value !== undefined) {
191
+ const num = parseFloat(value)
192
+ if (!isNaN(num)) return num
193
+ }
194
+ return undefined
195
+ }
196
+
197
+ /**
198
+ * Get a point from (tag x y) format
199
+ */
200
+ export function getPoint(
201
+ expr: SExpr,
202
+ tag: string
203
+ ): { x: number; y: number } | undefined {
204
+ const child = findChild(expr, tag)
205
+ if (child && child.length >= 3) {
206
+ const x = parseFloat(isAtom(child[1]) ? child[1] : '')
207
+ const y = parseFloat(isAtom(child[2]) ? child[2] : '')
208
+ if (!isNaN(x) && !isNaN(y)) {
209
+ return { x, y }
210
+ }
211
+ }
212
+ return undefined
213
+ }
214
+
215
+ /**
216
+ * Get point with optional rotation: (tag x y [rotation])
217
+ */
218
+ export function getPointWithRotation(
219
+ expr: SExpr,
220
+ tag: string
221
+ ): { x: number; y: number; rotation?: number } | undefined {
222
+ const child = findChild(expr, tag)
223
+ if (child && child.length >= 3) {
224
+ const x = parseFloat(isAtom(child[1]) ? child[1] : '')
225
+ const y = parseFloat(isAtom(child[2]) ? child[2] : '')
226
+ if (!isNaN(x) && !isNaN(y)) {
227
+ const result: { x: number; y: number; rotation?: number } = { x, y }
228
+ if (child.length >= 4 && isAtom(child[3])) {
229
+ const rotation = parseFloat(child[3])
230
+ if (!isNaN(rotation)) {
231
+ result.rotation = rotation
232
+ }
233
+ }
234
+ return result
235
+ }
236
+ }
237
+ return undefined
238
+ }
239
+
240
+ /**
241
+ * Get size from (size w h) format
242
+ */
243
+ export function getSize(
244
+ expr: SExpr
245
+ ): { width: number; height: number } | undefined {
246
+ const child = findChild(expr, 'size')
247
+ if (child && child.length >= 3) {
248
+ const width = parseFloat(isAtom(child[1]) ? child[1] : '')
249
+ const height = parseFloat(isAtom(child[2]) ? child[2] : '')
250
+ if (!isNaN(width) && !isNaN(height)) {
251
+ return { width, height }
252
+ }
253
+ }
254
+ return undefined
255
+ }
256
+
257
+ /**
258
+ * Get stroke properties from (stroke ...) element
259
+ */
260
+ export function getStroke(
261
+ expr: SExpr
262
+ ): { width: number; type: string } | undefined {
263
+ const stroke = findChild(expr, 'stroke')
264
+ if (!stroke) return undefined
265
+
266
+ const width = getNumericAttr(stroke, 'width') ?? 0.254
267
+ const type = getAttr(stroke, 'type') ?? 'default'
268
+
269
+ return { width, type }
270
+ }
271
+
272
+ /**
273
+ * Get fill type from (fill (type ...)) element
274
+ */
275
+ export function getFillType(expr: SExpr): string | undefined {
276
+ const fill = findChild(expr, 'fill')
277
+ if (!fill) return undefined
278
+ return getAttr(fill, 'type')
279
+ }
280
+
281
+ /**
282
+ * Get all XY points from (pts (xy x y) (xy x y) ...) structure
283
+ */
284
+ export function getPoints(expr: SExpr): Array<{ x: number; y: number }> {
285
+ const pts = findChild(expr, 'pts')
286
+ if (!pts) return []
287
+
288
+ const points: Array<{ x: number; y: number }> = []
289
+ for (const child of pts) {
290
+ if (isList(child) && getTag(child) === 'xy' && child.length >= 3) {
291
+ const x = parseFloat(isAtom(child[1]) ? child[1] : '')
292
+ const y = parseFloat(isAtom(child[2]) ? child[2] : '')
293
+ if (!isNaN(x) && !isNaN(y)) {
294
+ points.push({ x, y })
295
+ }
296
+ }
297
+ }
298
+
299
+ return points
300
+ }
301
+
302
+ /**
303
+ * Get layers from (layers "Layer1" "Layer2" ...) or (layer "Layer")
304
+ */
305
+ export function getLayers(expr: SExpr): string[] {
306
+ // Check for single layer
307
+ const layer = getAttr(expr, 'layer')
308
+ if (layer) return [layer]
309
+
310
+ // Check for multiple layers
311
+ const layers = findChild(expr, 'layers')
312
+ if (!layers) return []
313
+
314
+ const result: string[] = []
315
+ for (let i = 1; i < layers.length; i++) {
316
+ if (isAtom(layers[i])) {
317
+ result.push(layers[i])
318
+ }
319
+ }
320
+ return result
321
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * HTTP routes for the EasyEDA Component Browser
3
+ * Handles serving the HTML page and proxying API requests
4
+ *
5
+ * Returns KiCad S-expression strings for symbol/footprint preview rendering
6
+ */
7
+
8
+ import { readFileSync } from 'fs'
9
+ import { join, dirname } from 'path'
10
+ import { fileURLToPath } from 'url'
11
+ import type { IncomingMessage, ServerResponse } from 'http'
12
+ import {
13
+ createLogger,
14
+ type EasyEDAComponentData,
15
+ type EasyEDACommunityComponent,
16
+ easyedaCommunityClient,
17
+ symbolConverter,
18
+ footprintConverter,
19
+ } from '@jlcpcb/core'
20
+
21
+ const logger = createLogger('http-routes')
22
+
23
+ // Get the directory of this file to find assets
24
+ const __filename = fileURLToPath(import.meta.url)
25
+ const __dirname = dirname(__filename)
26
+
27
+ // Cache the HTML content in memory
28
+ let htmlCache: string | null = null
29
+
30
+ /**
31
+ * Load the HTML page from the assets directory
32
+ */
33
+ function getHtmlPage(): string {
34
+ if (htmlCache) return htmlCache
35
+
36
+ try {
37
+ // When bundled, __dirname points to dist/ since everything is in index.js
38
+ // When running from source, __dirname is src/http/
39
+ const possiblePaths = [
40
+ // Bundled: dist/index.js looking for dist/assets/search.html
41
+ join(__dirname, 'assets/search.html'),
42
+ // Bundled alternative path
43
+ join(__dirname, '../dist/assets/search.html'),
44
+ // Source: src/http/ looking for src/assets/
45
+ join(__dirname, '../assets/search.html'),
46
+ join(__dirname, '../assets/search-built.html'),
47
+ // From project root
48
+ join(process.cwd(), 'dist/assets/search.html'),
49
+ join(process.cwd(), 'packages/jlc-mcp/dist/assets/search.html'),
50
+ ]
51
+
52
+ for (const path of possiblePaths) {
53
+ try {
54
+ htmlCache = readFileSync(path, 'utf-8')
55
+ logger.debug(`Loaded HTML from: ${path}`)
56
+ return htmlCache
57
+ } catch {
58
+ // Try next path
59
+ }
60
+ }
61
+
62
+ throw new Error('HTML file not found')
63
+ } catch (error) {
64
+ logger.error('Failed to load HTML page:', error)
65
+ return `<!DOCTYPE html>
66
+ <html>
67
+ <head><title>Error</title></head>
68
+ <body>
69
+ <h1>Error: Search page not found</h1>
70
+ <p>The search page has not been built. Run: bun run build</p>
71
+ </body>
72
+ </html>`
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Main request handler
78
+ */
79
+ export async function handleRequest(
80
+ req: IncomingMessage,
81
+ res: ServerResponse
82
+ ): Promise<void> {
83
+ const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`)
84
+ const pathname = url.pathname
85
+
86
+ logger.debug(`${req.method} ${pathname}`)
87
+
88
+ // Route requests
89
+ if (pathname === '/' || pathname === '/index.html') {
90
+ serveHtml(res)
91
+ } else if (pathname === '/api/search') {
92
+ await handleSearch(url, res)
93
+ } else if (pathname.startsWith('/api/component/')) {
94
+ const uuid = pathname.replace('/api/component/', '')
95
+ await handleComponent(uuid, res)
96
+ } else if (pathname === '/health') {
97
+ res.writeHead(200, { 'Content-Type': 'application/json' })
98
+ res.end(JSON.stringify({ status: 'ok' }))
99
+ } else {
100
+ res.writeHead(404, { 'Content-Type': 'application/json' })
101
+ res.end(JSON.stringify({ error: 'Not found' }))
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Serve the HTML page
107
+ */
108
+ function serveHtml(res: ServerResponse): void {
109
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
110
+ res.end(getHtmlPage())
111
+ }
112
+
113
+ /**
114
+ * Handle search API requests
115
+ * GET /api/search?q=query&source=user|lcsc|all&page=1&limit=20
116
+ */
117
+ async function handleSearch(
118
+ url: URL,
119
+ res: ServerResponse
120
+ ): Promise<void> {
121
+ const query = url.searchParams.get('q') || ''
122
+ const source = url.searchParams.get('source') || 'user'
123
+ const page = parseInt(url.searchParams.get('page') || '1', 10)
124
+ const limit = parseInt(url.searchParams.get('limit') || '20', 10)
125
+
126
+ if (!query) {
127
+ res.writeHead(400, { 'Content-Type': 'application/json' })
128
+ res.end(JSON.stringify({ error: 'Missing query parameter' }))
129
+ return
130
+ }
131
+
132
+ try {
133
+ // Fetch more than we need for pagination (EasyEDA doesn't support offset)
134
+ const allResults = await easyedaCommunityClient.search({
135
+ query,
136
+ source: source as 'user' | 'lcsc' | 'easyeda' | 'all',
137
+ limit: Math.min(limit * page + limit, 100), // Max 100 results
138
+ })
139
+
140
+ // Calculate pagination
141
+ const startIndex = (page - 1) * limit
142
+ const endIndex = startIndex + limit
143
+ const results = allResults.slice(startIndex, endIndex)
144
+ const totalPages = Math.ceil(allResults.length / limit)
145
+
146
+ res.writeHead(200, { 'Content-Type': 'application/json' })
147
+ res.end(JSON.stringify({
148
+ results,
149
+ pagination: {
150
+ page,
151
+ limit,
152
+ total: allResults.length,
153
+ totalPages,
154
+ hasNext: page < totalPages,
155
+ hasPrev: page > 1,
156
+ },
157
+ }))
158
+ } catch (error) {
159
+ logger.error('Search error:', error)
160
+ res.writeHead(500, { 'Content-Type': 'application/json' })
161
+ res.end(JSON.stringify({ error: 'Search failed' }))
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Convert EasyEDACommunityComponent to EasyEDAComponentData format
167
+ * Required because converters expect the LCSC-style structure
168
+ */
169
+ function communityToComponentData(
170
+ community: EasyEDACommunityComponent
171
+ ): EasyEDAComponentData {
172
+ // Extract component info from the head c_para
173
+ const cPara = community.symbol.head?.c_para as Record<string, string> | undefined ?? {}
174
+ const fpCPara = community.footprint.head?.c_para as Record<string, string> | undefined ?? {}
175
+
176
+ return {
177
+ info: {
178
+ name: community.title || cPara.name || 'Unknown',
179
+ prefix: cPara.pre || cPara.Prefix || 'U',
180
+ package: fpCPara.package || community.footprint.name,
181
+ manufacturer: cPara.Manufacturer || cPara.BOM_Manufacturer,
182
+ description: community.description || cPara.BOM_Manufacturer_Part,
183
+ category: cPara.package, // Best guess for category
184
+ },
185
+ symbol: community.symbol,
186
+ footprint: community.footprint,
187
+ model3d: community.model3d,
188
+ rawData: community.rawData,
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Handle component fetch API requests
194
+ * GET /api/component/:uuid
195
+ *
196
+ * Returns KiCad S-expression strings for browser-side rendering
197
+ */
198
+ async function handleComponent(
199
+ uuid: string,
200
+ res: ServerResponse
201
+ ): Promise<void> {
202
+ if (!uuid) {
203
+ res.writeHead(400, { 'Content-Type': 'application/json' })
204
+ res.end(JSON.stringify({ error: 'Missing UUID' }))
205
+ return
206
+ }
207
+
208
+ try {
209
+ const component = await easyedaCommunityClient.getComponent(uuid)
210
+
211
+ if (!component) {
212
+ res.writeHead(404, { 'Content-Type': 'application/json' })
213
+ res.end(JSON.stringify({ error: 'Component not found' }))
214
+ return
215
+ }
216
+
217
+ // Convert community component to converter-compatible format
218
+ const componentData = communityToComponentData(component)
219
+
220
+ // Generate KiCad S-expression strings using existing converters
221
+ let symbolSexpr = ''
222
+ let footprintSexpr = ''
223
+
224
+ try {
225
+ // Get just the symbol entry (not the full library wrapper)
226
+ symbolSexpr = symbolConverter.convertToSymbolEntry(componentData)
227
+ } catch (e) {
228
+ logger.warn('Symbol conversion failed:', e)
229
+ }
230
+
231
+ try {
232
+ footprintSexpr = footprintConverter.convert(componentData)
233
+ } catch (e) {
234
+ logger.warn('Footprint conversion failed:', e)
235
+ }
236
+
237
+ // Return S-expr strings for browser rendering
238
+ res.writeHead(200, { 'Content-Type': 'application/json' })
239
+ res.end(JSON.stringify({
240
+ uuid: component.uuid,
241
+ title: component.title,
242
+ description: component.description,
243
+ owner: component.owner,
244
+ symbolSexpr,
245
+ footprintSexpr,
246
+ model3d: component.model3d,
247
+ }))
248
+ } catch (error) {
249
+ logger.error('Component fetch error:', error)
250
+ res.writeHead(500, { 'Content-Type': 'application/json' })
251
+ res.end(JSON.stringify({ error: 'Failed to fetch component' }))
252
+ }
253
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * HTTP server for the EasyEDA Component Browser
3
+ * Runs alongside the MCP stdio server to serve the web UI and proxy API requests
4
+ */
5
+
6
+ import { createServer, type IncomingMessage, type ServerResponse } from 'http'
7
+ import { createLogger } from '@jlcpcb/core'
8
+ import { handleRequest } from './routes.js'
9
+
10
+ const logger = createLogger('http-server')
11
+
12
+ const DEFAULT_PORT = 3847
13
+
14
+ let serverInstance: ReturnType<typeof createServer> | null = null
15
+
16
+ /**
17
+ * Start the HTTP server
18
+ * @returns The port the server is listening on
19
+ */
20
+ export function startHttpServer(): number {
21
+ if (serverInstance) {
22
+ logger.debug('HTTP server already running')
23
+ return DEFAULT_PORT
24
+ }
25
+
26
+ const port = parseInt(process.env.JLC_MCP_HTTP_PORT || String(DEFAULT_PORT), 10)
27
+
28
+ serverInstance = createServer(async (req: IncomingMessage, res: ServerResponse) => {
29
+ // Add CORS headers to all responses
30
+ res.setHeader('Access-Control-Allow-Origin', '*')
31
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
32
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
33
+
34
+ // Handle preflight requests
35
+ if (req.method === 'OPTIONS') {
36
+ res.writeHead(204)
37
+ res.end()
38
+ return
39
+ }
40
+
41
+ try {
42
+ await handleRequest(req, res)
43
+ } catch (error) {
44
+ logger.error('Request error:', error)
45
+ res.writeHead(500, { 'Content-Type': 'application/json' })
46
+ res.end(JSON.stringify({ error: 'Internal server error' }))
47
+ }
48
+ })
49
+
50
+ serverInstance.listen(port, () => {
51
+ logger.info(`HTTP server listening on http://localhost:${port}`)
52
+ })
53
+
54
+ serverInstance.on('error', (error: NodeJS.ErrnoException) => {
55
+ if (error.code === 'EADDRINUSE') {
56
+ logger.warn(`Port ${port} already in use, HTTP server not started`)
57
+ } else {
58
+ logger.error('HTTP server error:', error)
59
+ }
60
+ })
61
+
62
+ return port
63
+ }
64
+
65
+ /**
66
+ * Stop the HTTP server
67
+ */
68
+ export function stopHttpServer(): void {
69
+ if (serverInstance) {
70
+ serverInstance.close()
71
+ serverInstance = null
72
+ logger.info('HTTP server stopped')
73
+ }
74
+ }