@jurislm/judicial-mcp 1.3.0 → 1.6.2

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,536 @@
1
+ import axios, { type AxiosResponse } from 'axios'
2
+ import { load } from 'cheerio'
3
+ import {
4
+ deleteRegulationCacheEntry,
5
+ getRegulationCacheEntry,
6
+ setRegulationCacheEntry,
7
+ type RegulationCacheEntry,
8
+ } from './regulation-cache.js'
9
+ import { RegulationSourceError } from './source-error.js'
10
+
11
+ const BASE_URL = 'https://law.moj.gov.tw'
12
+ const TERM_URL = new URL('/controls/GetTermList.ashx', BASE_URL)
13
+ const RESPONSE_LIMIT = 2_000_000
14
+ const FIRECRAWL_TIMEOUT_MS = 30_000
15
+ const FIRECRAWL_REQUEST_TIMEOUT_MS = 40_000
16
+ const MAX_ARTICLE_LENGTH = 30_000
17
+ const MAX_CANDIDATES = 10
18
+ const RESPONSE_TYPE: 'text' = 'text'
19
+
20
+ interface RegulationCandidate {
21
+ name: string
22
+ identifier: string
23
+ }
24
+
25
+ interface RegulationVersion {
26
+ amendment_date: string | null
27
+ promulgation_date: string | null
28
+ effective_date: string | null
29
+ effective_status: 'as_stated' | 'pending_or_mixed' | 'unknown'
30
+ }
31
+
32
+ interface RegulationResult {
33
+ status: 'current' | 'ambiguous'
34
+ regulation_name?: string
35
+ regulation_id?: string
36
+ article_number?: string
37
+ article_text?: string
38
+ version?: RegulationVersion
39
+ candidates?: Array<{ regulation_name: string; regulation_id: string }>
40
+ source_url: string
41
+ search_source_url: string
42
+ retrieved_at: string
43
+ cache: { status: 'miss' | 'hit' | 'refreshed' | 'revalidated' | 'not_cached' }
44
+ truncated: boolean
45
+ partial: boolean
46
+ source_version: string | null
47
+ }
48
+
49
+ interface ParsedArticle {
50
+ text: string
51
+ version: RegulationVersion
52
+ }
53
+
54
+ type CandidateResolution = { candidate: RegulationCandidate } | { ambiguous: RegulationResult }
55
+
56
+ type ValidatedRegulationCacheEntry = Omit<RegulationCacheEntry, 'value'> & { value: RegulationResult }
57
+
58
+ interface ArticleBlock {
59
+ find(selector: string): { first(): { text(): string; html(): string | null; length: number }; length: number }
60
+ text(): string
61
+ }
62
+
63
+ function isRecord(value: unknown): value is Record<string, unknown> {
64
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
65
+ }
66
+
67
+ function isNullableDate(value: unknown): boolean {
68
+ return value === null || (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value))
69
+ }
70
+
71
+ function isRegulationResult(value: unknown): value is RegulationResult {
72
+ if (!isRecord(value) || value.status !== 'current') return false
73
+ if (!isRecord(value.cache) || !isRecord(value.version)) return false
74
+ const cacheStatus = value.cache.status
75
+ const effectiveStatus = value.version.effective_status
76
+ return typeof value.regulation_name === 'string' &&
77
+ typeof value.regulation_id === 'string' &&
78
+ typeof value.article_number === 'string' &&
79
+ typeof value.article_text === 'string' &&
80
+ isNullableDate(value.version.amendment_date) &&
81
+ isNullableDate(value.version.promulgation_date) &&
82
+ isNullableDate(value.version.effective_date) &&
83
+ (effectiveStatus === 'as_stated' || effectiveStatus === 'pending_or_mixed' || effectiveStatus === 'unknown') &&
84
+ typeof value.source_url === 'string' &&
85
+ typeof value.search_source_url === 'string' &&
86
+ typeof value.retrieved_at === 'string' &&
87
+ (cacheStatus === 'miss' || cacheStatus === 'hit' || cacheStatus === 'refreshed' || cacheStatus === 'revalidated') &&
88
+ typeof value.truncated === 'boolean' &&
89
+ typeof value.partial === 'boolean' &&
90
+ (value.source_version === null || typeof value.source_version === 'string')
91
+ }
92
+
93
+ function validateName(value: unknown): string {
94
+ if (typeof value !== 'string' || value.trim().length === 0 || value.trim().length > 120) {
95
+ throw new Error('lawName 必須是 1 至 120 個字元的法規名稱')
96
+ }
97
+ return value.trim()
98
+ }
99
+
100
+ function validateArticleNumber(value: unknown): string {
101
+ if (typeof value !== 'string' || !/^\d{1,5}(?:-\d{1,5})?$/.test(value.trim())) {
102
+ throw new Error('articleNumber 必須使用阿拉伯數字,可含連字號,例如 1 或 1-1')
103
+ }
104
+ return value.trim().split('-').map(part => part.replace(/^0+(?=\d)/, '')).join('-')
105
+ }
106
+
107
+ function validateRefresh(value: unknown): boolean {
108
+ if (value === undefined) return false
109
+ if (typeof value !== 'boolean') throw new Error('refresh 必須是布林值')
110
+ return value
111
+ }
112
+
113
+ function normalizeName(value: string): string {
114
+ return value.replaceAll('台', '臺').replaceAll(/\s+/g, '').toLocaleLowerCase()
115
+ }
116
+
117
+ function termUrl(name: string): URL {
118
+ const url = new URL(TERM_URL.href)
119
+ url.searchParams.set('term', name)
120
+ url.searchParams.set('type', 'ONEBAR')
121
+ return url
122
+ }
123
+
124
+ function detailUrl(identifier: string): URL {
125
+ const url = new URL('/LawClass/LawAll.aspx', BASE_URL)
126
+ url.searchParams.set('pcode', identifier)
127
+ return url
128
+ }
129
+
130
+ function termRequestOptions() {
131
+ return {
132
+ timeout: 15_000,
133
+ maxContentLength: RESPONSE_LIMIT,
134
+ maxBodyLength: RESPONSE_LIMIT,
135
+ maxRedirects: 0,
136
+ responseType: RESPONSE_TYPE,
137
+ validateStatus: (status: number) => status >= 200 && status < 300,
138
+ headers: {
139
+ Accept: 'application/json,text/plain',
140
+ 'Content-Type': 'application/x-www-form-urlencoded',
141
+ 'User-Agent': 'judicial-mcp/1.5 (https://github.com/jurislm/judicial-mcp)',
142
+ },
143
+ }
144
+ }
145
+
146
+ function firecrawlEndpoint(sourceUrl: URL): { endpoint: URL; apiKey?: string } {
147
+ const configuredUrl = process.env.FIRECRAWL_API_URL?.trim()
148
+ if (!configuredUrl) throw new RegulationSourceError('source_unavailable', sourceUrl.href)
149
+ let endpoint: URL
150
+ try {
151
+ endpoint = new URL(configuredUrl)
152
+ } catch {
153
+ throw new RegulationSourceError('source_unavailable', sourceUrl.href)
154
+ }
155
+ if (
156
+ (endpoint.protocol !== 'http:' && endpoint.protocol !== 'https:') ||
157
+ endpoint.username.length > 0 || endpoint.password.length > 0 ||
158
+ endpoint.search.length > 0 || endpoint.hash.length > 0
159
+ ) throw new RegulationSourceError('source_unavailable', sourceUrl.href)
160
+ const apiKey = process.env.FIRECRAWL_API_KEY?.trim()
161
+ const loopbackHosts = new Set(['localhost', '127.0.0.1', '::1', '[::1]'])
162
+ if (apiKey && endpoint.protocol === 'http:' && !loopbackHosts.has(endpoint.hostname.toLocaleLowerCase())) {
163
+ throw new RegulationSourceError('source_unavailable', sourceUrl.href)
164
+ }
165
+ endpoint.pathname = `${endpoint.pathname.replace(/\/+$/, '')}/v1/scrape`
166
+ return apiKey ? { endpoint, apiKey } : { endpoint }
167
+ }
168
+
169
+ function firecrawlRequestOptions(apiKey?: string) {
170
+ const headers: Record<string, string> = {
171
+ Accept: 'application/json',
172
+ 'Content-Type': 'application/json',
173
+ }
174
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`
175
+ return {
176
+ timeout: FIRECRAWL_REQUEST_TIMEOUT_MS,
177
+ maxContentLength: RESPONSE_LIMIT,
178
+ maxBodyLength: RESPONSE_LIMIT,
179
+ maxRedirects: 0,
180
+ validateStatus: (status: number) => status >= 200 && status < 300,
181
+ headers,
182
+ }
183
+ }
184
+
185
+ function sourceFailure(error: unknown, url: URL, notFoundOn404 = true): never {
186
+ const status = error !== null && typeof error === 'object' && 'response' in error
187
+ ? (error.response !== null && typeof error.response === 'object' && 'status' in error.response
188
+ ? error.response.status
189
+ : undefined)
190
+ : undefined
191
+ throw new RegulationSourceError(status === 404 && notFoundOn404 ? 'regulation_not_found' : 'source_unavailable', url.href)
192
+ }
193
+
194
+ function responseText(response: AxiosResponse<string>, url: URL): string {
195
+ if (typeof response.data !== 'string' || response.data.length > RESPONSE_LIMIT) {
196
+ throw new RegulationSourceError('source_changed', url.href)
197
+ }
198
+ return response.data
199
+ }
200
+
201
+ function responseHtml(payload: unknown, url: URL): string {
202
+ if (!isRecord(payload)) throw new RegulationSourceError('source_changed', url.href)
203
+ if (payload.success !== true) throw new RegulationSourceError('source_unavailable', url.href)
204
+ if (!isRecord(payload.data) || typeof payload.data.rawHtml !== 'string' || payload.data.rawHtml.length === 0 || payload.data.rawHtml.length > RESPONSE_LIMIT) {
205
+ throw new RegulationSourceError('source_changed', url.href)
206
+ }
207
+ const metadata = payload.data.metadata
208
+ if (!isRecord(metadata) || typeof metadata.sourceURL !== 'string') {
209
+ throw new RegulationSourceError('source_changed', url.href)
210
+ }
211
+ let resolvedUrl: URL
212
+ try {
213
+ resolvedUrl = new URL(metadata.sourceURL)
214
+ } catch {
215
+ throw new RegulationSourceError('source_changed', url.href)
216
+ }
217
+ const requestedPcode = url.searchParams.get('pcode')
218
+ const sourceParameters = [...resolvedUrl.searchParams.entries()]
219
+ if (
220
+ resolvedUrl.origin !== url.origin ||
221
+ resolvedUrl.username.length > 0 ||
222
+ resolvedUrl.password.length > 0 ||
223
+ resolvedUrl.pathname !== url.pathname ||
224
+ resolvedUrl.hash.length > 0 ||
225
+ requestedPcode === null ||
226
+ sourceParameters.length !== 1 ||
227
+ resolvedUrl.searchParams.getAll('pcode').length !== 1 ||
228
+ resolvedUrl.searchParams.get('pcode') !== requestedPcode
229
+ ) {
230
+ throw new RegulationSourceError('source_changed', url.href)
231
+ }
232
+ return payload.data.rawHtml
233
+ }
234
+
235
+ function validPcode(value: string | undefined): value is string {
236
+ return value !== undefined && /^[A-Z]\d{7}$/.test(value.toLocaleUpperCase())
237
+ }
238
+
239
+ function parseTermCandidates(payload: string, url: URL): RegulationCandidate[] {
240
+ let parsed: unknown
241
+ try {
242
+ parsed = JSON.parse(payload)
243
+ } catch {
244
+ throw new RegulationSourceError('source_changed', url.href)
245
+ }
246
+ if (!Array.isArray(parsed)) throw new RegulationSourceError('source_changed', url.href)
247
+ const candidates: RegulationCandidate[] = []
248
+ for (const item of parsed) {
249
+ if (!isRecord(item) || typeof item.PCODE !== 'string' || typeof item.TERM !== 'string') {
250
+ throw new RegulationSourceError('source_changed', url.href)
251
+ }
252
+ const identifier = item.PCODE.trim()
253
+ const name = item.TERM.replaceAll(/\s+/g, ' ').trim()
254
+ if (identifier.length === 0 || name.length === 0) continue
255
+ if (!validPcode(identifier)) throw new RegulationSourceError('source_changed', url.href)
256
+ const normalizedIdentifier = identifier.toLocaleUpperCase()
257
+ const duplicate = candidates.find(candidate => candidate.identifier === normalizedIdentifier && normalizeName(candidate.name) === normalizeName(name))
258
+ if (!duplicate) candidates.push({ name, identifier: normalizedIdentifier })
259
+ }
260
+ return candidates
261
+ }
262
+
263
+ function parseRocDate(value: string): string | null {
264
+ const match = value.match(/民國\s*(\d{1,3})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日/)
265
+ if (!match) return null
266
+ const year = Number(match[1]) + 1911
267
+ const month = Number(match[2])
268
+ const day = Number(match[3])
269
+ const parsed = new Date(Date.UTC(year, month - 1, day))
270
+ if (
271
+ month < 1 || month > 12 || day < 1 || day > 31 ||
272
+ parsed.getUTCFullYear() !== year || parsed.getUTCMonth() !== month - 1 || parsed.getUTCDate() !== day
273
+ ) return null
274
+ return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
275
+ }
276
+
277
+ function dateForLabel(text: string, labels: string[]): string | null {
278
+ for (const label of labels) {
279
+ const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
280
+ const match = text.match(new RegExp(`${escaped}\\s*[::]?\\s*(?:\\|\\s*)?(民國\\s*\\d{1,3}\\s*年\\s*\\d{1,2}\\s*月\\s*\\d{1,2}\\s*日)`))
281
+ if (match?.[1]) return parseRocDate(match[1])
282
+ }
283
+ return null
284
+ }
285
+
286
+ function normalizeArticleText(value: string): string {
287
+ return value
288
+ .replaceAll('\u00a0', ' ')
289
+ .replaceAll(/[ \t]+/g, ' ')
290
+ .replaceAll(/\s*\n\s*/g, '\n')
291
+ .replaceAll(/\n{3,}/g, '\n\n')
292
+ .trim()
293
+ }
294
+
295
+ function normalizeArticleHtml(value: string): string {
296
+ const $ = load(`<div>${value}</div>`)
297
+ $('script, style').remove()
298
+ $('br').replaceWith('\n')
299
+ $('p, li').each((_, element) => {
300
+ $(element).prepend('\n').append('\n')
301
+ })
302
+ return normalizeArticleText($('div').first().text())
303
+ }
304
+
305
+ function regulationMetadataText(pageText: string): string {
306
+ const firstArticle = pageText.search(/第\s*\d+(?:-\d+)?\s*條/)
307
+ return firstArticle < 0 ? pageText : pageText.slice(0, firstArticle)
308
+ }
309
+
310
+ function articleHeading(block: ArticleBlock): string {
311
+ const heading = block.find('.col-no, .law-reg-no, .article-number').first()
312
+ const html = heading.html()
313
+ if (html !== null) {
314
+ const $heading = load(`<div>${html}</div>`)
315
+ let linkedHeading: string | undefined
316
+ $heading('a').each((_, anchor) => {
317
+ const text = $heading(anchor).text().trim()
318
+ if (!linkedHeading && /^第\s*\d+(?:-\d+)?\s*條$/.test(text)) linkedHeading = text
319
+ })
320
+ if (linkedHeading) return linkedHeading
321
+ }
322
+ return heading.text().trim()
323
+ }
324
+
325
+ function articleBlockText(block: ArticleBlock, articleNumber: string): string | undefined {
326
+ const heading = articleHeading(block)
327
+ if (!heading || !new RegExp(`^第\\s*${articleNumber.replace('-', '\\-')}\\s*條`).test(heading)) return undefined
328
+ const content = block.find('.col-data, .law-reg-content-text, .article-content').first()
329
+ if (!content.length) return undefined
330
+ const html = content.html()
331
+ if (html === null) return undefined
332
+ return normalizeArticleHtml(html)
333
+ }
334
+
335
+ function isArticleBlock(block: ArticleBlock): boolean {
336
+ const heading = articleHeading(block)
337
+ const content = block.find('.col-data, .law-reg-content-text, .article-content').first()
338
+ return /^第\s*\d+(?:-\d+)?\s*條/.test(heading) && content.length > 0 && content.text().trim().length > 0
339
+ }
340
+
341
+ function parseArticle(html: string, url: URL, lawName: string, articleNumber: string): ParsedArticle {
342
+ const $ = load(html)
343
+ const pageText = normalizeArticleText($('main, #content, #law_content').first().text() || $('body').text())
344
+ const metadataText = regulationMetadataText(pageText)
345
+ if (/法規名稱\s*[::]\s*廢\s/.test(metadataText) || /廢止日期\s*[::]/.test(metadataText)) {
346
+ throw new RegulationSourceError('regulation_abolished', url.href)
347
+ }
348
+ if (!pageText || !normalizeName(pageText).includes(normalizeName(lawName))) {
349
+ throw new RegulationSourceError('source_changed', url.href)
350
+ }
351
+ let articleText: string | undefined
352
+ const numberedRows = $('.law-reg-content > .row').toArray()
353
+ const blocks = numberedRows.length > 0
354
+ ? numberedRows
355
+ : $('.law-reg-content, .law-article, .article-content-row, [id^="law-reg-content"]').toArray()
356
+ if (blocks.length === 0 || blocks.some(block => !isArticleBlock($(block)))) {
357
+ throw new RegulationSourceError('source_changed', url.href)
358
+ }
359
+ for (const block of blocks) {
360
+ articleText = articleBlockText($(block), articleNumber)
361
+ if (articleText) break
362
+ }
363
+ if (!articleText) {
364
+ throw new RegulationSourceError('regulation_article_not_found', url.href)
365
+ }
366
+ const version: RegulationVersion = {
367
+ amendment_date: dateForLabel(metadataText, ['修正日期', '修正日']),
368
+ promulgation_date: dateForLabel(metadataText, ['公布日期', '發布日期']),
369
+ effective_date: dateForLabel(metadataText, ['施行日期', '生效日期']),
370
+ effective_status: /尚未生效|尚未施行|最後生效日期:未定/.test(metadataText) ? 'pending_or_mixed' : 'as_stated',
371
+ }
372
+ if (/最後生效日期:未定/.test(metadataText) && version.effective_date === null) {
373
+ version.effective_status = 'unknown'
374
+ }
375
+ return { text: articleText, version }
376
+ }
377
+
378
+ function makeResult(
379
+ candidate: RegulationCandidate,
380
+ articleNumber: string,
381
+ article: ParsedArticle,
382
+ sourceUrl: URL,
383
+ searchSourceUrl: URL,
384
+ retrievedAt: string,
385
+ cacheStatus: RegulationResult['cache']['status'],
386
+ ): RegulationResult {
387
+ const truncated = article.text.length > MAX_ARTICLE_LENGTH
388
+ const missingDates = article.version.amendment_date === null ||
389
+ article.version.promulgation_date === null || article.version.effective_date === null
390
+ const text = article.text.slice(0, MAX_ARTICLE_LENGTH)
391
+ return {
392
+ status: 'current',
393
+ regulation_name: candidate.name,
394
+ regulation_id: candidate.identifier,
395
+ article_number: articleNumber,
396
+ article_text: text,
397
+ version: article.version,
398
+ source_url: sourceUrl.href,
399
+ search_source_url: searchSourceUrl.href,
400
+ retrieved_at: retrievedAt,
401
+ cache: { status: cacheStatus },
402
+ truncated,
403
+ partial: truncated || missingDates || article.version.effective_status !== 'as_stated',
404
+ source_version: article.version.amendment_date ?? article.version.promulgation_date,
405
+ }
406
+ }
407
+
408
+ function resultWithCacheStatus(result: RegulationResult, status: RegulationResult['cache']['status'], retrievedAt = result.retrieved_at): RegulationResult {
409
+ return { ...result, retrieved_at: retrievedAt, cache: { status } }
410
+ }
411
+
412
+ function cacheKey(lawName: string, articleNumber: string): string {
413
+ return `${normalizeName(lawName)}\u0000${articleNumber}`
414
+ }
415
+
416
+ function ambiguousResult(candidates: RegulationCandidate[], lookupURL: URL): RegulationResult {
417
+ return {
418
+ status: 'ambiguous',
419
+ candidates: candidates.slice(0, MAX_CANDIDATES).map(candidate => ({ regulation_name: candidate.name, regulation_id: candidate.identifier })),
420
+ source_url: lookupURL.href,
421
+ search_source_url: lookupURL.href,
422
+ retrieved_at: new Date().toISOString(),
423
+ cache: { status: 'not_cached' },
424
+ truncated: candidates.length > MAX_CANDIDATES,
425
+ partial: true,
426
+ source_version: null,
427
+ }
428
+ }
429
+
430
+ function resolveCandidate(candidates: RegulationCandidate[], lawName: string, lookupURL: URL): CandidateResolution {
431
+ const requested = normalizeName(lawName)
432
+ const exact = candidates.filter(candidate => normalizeName(candidate.name) === requested)
433
+ if (exact.length === 1) {
434
+ const candidate = exact[0]
435
+ if (candidate) return { candidate }
436
+ throw new RegulationSourceError('source_changed', lookupURL.href)
437
+ }
438
+ if (candidates.length === 0) throw new RegulationSourceError('regulation_not_found', lookupURL.href)
439
+ return { ambiguous: ambiguousResult(exact.length > 1 ? exact : candidates, lookupURL) }
440
+ }
441
+
442
+ async function fetchHtml(url: URL): Promise<string> {
443
+ const { endpoint, apiKey } = firecrawlEndpoint(url)
444
+ let response: AxiosResponse<unknown>
445
+ try {
446
+ response = await axios.post<unknown>(
447
+ endpoint.href,
448
+ {
449
+ url: url.href,
450
+ formats: ['rawHtml'],
451
+ onlyMainContent: false,
452
+ timeout: FIRECRAWL_TIMEOUT_MS,
453
+ },
454
+ firecrawlRequestOptions(apiKey),
455
+ )
456
+ } catch (error: unknown) {
457
+ sourceFailure(error, url, false)
458
+ }
459
+ return responseHtml(response.data, url)
460
+ }
461
+
462
+ async function fetchTermCandidates(name: string, url: URL): Promise<RegulationCandidate[]> {
463
+ let response: AxiosResponse<string>
464
+ try {
465
+ const options = termRequestOptions()
466
+ response = await axios.post<string>(
467
+ url.href,
468
+ new URLSearchParams({ term: name, type: 'ONEBAR' }).toString(),
469
+ options,
470
+ )
471
+ } catch (error: unknown) {
472
+ sourceFailure(error, url, false)
473
+ }
474
+ if (response.status < 200 || response.status >= 300) throw new RegulationSourceError('source_changed', url.href)
475
+ return parseTermCandidates(responseText(response, url), url)
476
+ }
477
+
478
+ async function lookupRegulation(lawName: string): Promise<{ resolution: CandidateResolution; lookupURL: URL }> {
479
+ const directURL = termUrl(lawName)
480
+ const directCandidates = await fetchTermCandidates(lawName, directURL)
481
+ return { resolution: resolveCandidate(directCandidates, lawName, directURL), lookupURL: directURL }
482
+ }
483
+
484
+ export async function queryRegulation(args: Record<string, unknown>): Promise<RegulationResult> {
485
+ const lawName = validateName(args.lawName)
486
+ const articleNumber = validateArticleNumber(args.articleNumber)
487
+ const refresh = validateRefresh(args.refresh)
488
+ const key = cacheKey(lawName, articleNumber)
489
+ const now = Date.now()
490
+ const rawCached = getRegulationCacheEntry(key)
491
+ let cached: ValidatedRegulationCacheEntry | undefined
492
+ if (rawCached) {
493
+ if (isRegulationResult(rawCached.value)) cached = { ...rawCached, value: rawCached.value }
494
+ else deleteRegulationCacheEntry(key)
495
+ }
496
+
497
+ if (cached && cached.expiresAt > now && !refresh) {
498
+ return resultWithCacheStatus(cached.value, 'hit')
499
+ }
500
+
501
+ if (refresh) deleteRegulationCacheEntry(key)
502
+ if (cached && cached.expiresAt <= now && !refresh) {
503
+ const checkedAt = new Date().toISOString()
504
+ try {
505
+ const lookup = await lookupRegulation(lawName)
506
+ const { lookupURL, resolution } = lookup
507
+ if ('ambiguous' in resolution) {
508
+ deleteRegulationCacheEntry(key)
509
+ return resolution.ambiguous
510
+ }
511
+ const candidate = resolution.candidate
512
+ const detailURL = detailUrl(candidate.identifier)
513
+ const html = await fetchHtml(detailURL)
514
+ const article = parseArticle(html, detailURL, candidate.name, articleNumber)
515
+ const result = makeResult(candidate, articleNumber, article, detailURL, lookupURL, checkedAt, 'revalidated')
516
+ setRegulationCacheEntry(key, { value: result }, now)
517
+ return result
518
+ } catch (error: unknown) {
519
+ deleteRegulationCacheEntry(key)
520
+ throw error
521
+ }
522
+ }
523
+
524
+ const lookup = await lookupRegulation(lawName)
525
+ const { lookupURL, resolution } = lookup
526
+ if ('ambiguous' in resolution) return resolution.ambiguous
527
+ const candidate = resolution.candidate
528
+
529
+ const articleURL = detailUrl(candidate.identifier)
530
+ const html = await fetchHtml(articleURL)
531
+ const article = parseArticle(html, articleURL, candidate.name, articleNumber)
532
+ const retrievedAt = new Date().toISOString()
533
+ const result = makeResult(candidate, articleNumber, article, articleURL, lookupURL, retrievedAt, refresh ? 'refreshed' : 'miss')
534
+ setRegulationCacheEntry(key, { value: result }, now)
535
+ return result
536
+ }
@@ -0,0 +1,74 @@
1
+ import { JudicialSourceError, RegulationSourceError } from './source-error.js'
2
+
3
+ type ContentBlock =
4
+ | { type: 'text'; text: string }
5
+ | { type: 'resource'; resource: { uri: string; mimeType: string; blob: string } }
6
+
7
+ export interface McpToolResult {
8
+ content: ContentBlock[]
9
+ isError?: boolean
10
+ [key: string]: unknown
11
+ }
12
+
13
+ export const createSuccessResponse = (data: unknown): McpToolResult => ({
14
+ content: [
15
+ {
16
+ type: 'text',
17
+ text: JSON.stringify(data, null, 2),
18
+ },
19
+ ],
20
+ })
21
+
22
+ export const createErrorResponse = (error: unknown, message: string): McpToolResult => {
23
+ if (error instanceof JudicialSourceError || error instanceof RegulationSourceError) {
24
+ return {
25
+ content: [
26
+ {
27
+ type: 'text',
28
+ text: JSON.stringify({
29
+ error: {
30
+ code: error.code,
31
+ message: error.message,
32
+ source_url: error.sourceUrl,
33
+ retrieved_at: error.retrievedAt,
34
+ },
35
+ }, null, 2),
36
+ },
37
+ ],
38
+ isError: true,
39
+ }
40
+ }
41
+ const detail =
42
+ error !== null && typeof error === 'object' && 'response' in error
43
+ ? (error as { response?: { data?: unknown } }).response?.data
44
+ : error instanceof Error
45
+ ? error.message
46
+ : String(error)
47
+
48
+ return {
49
+ content: [
50
+ {
51
+ type: 'text',
52
+ text: JSON.stringify({ error: message, detail }, null, 2),
53
+ },
54
+ ],
55
+ isError: true,
56
+ }
57
+ }
58
+
59
+ export const createBlobResponse = (
60
+ data: Buffer | string,
61
+ mimeType: string,
62
+ uri: string,
63
+ ): McpToolResult => ({
64
+ content: [
65
+ {
66
+ type: 'resource',
67
+ resource: {
68
+ uri,
69
+ mimeType,
70
+ blob: Buffer.isBuffer(data) ? data.toString('base64') : data,
71
+ },
72
+ },
73
+ ],
74
+ })
package/src/server.ts ADDED
@@ -0,0 +1,70 @@
1
+ import 'dotenv/config'
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
4
+ import {
5
+ CallToolRequestSchema,
6
+ ListToolsRequestSchema,
7
+ } from '@modelcontextprotocol/sdk/types.js'
8
+ import { TOOLS_CONFIG, TOOL_HANDLERS } from './tools.js'
9
+ import { createSuccessResponse, createErrorResponse, type McpToolResult } from './response.js'
10
+ import { createRequire } from 'module'
11
+
12
+ const { version } = createRequire(import.meta.url)('../package.json') as { version: string }
13
+
14
+ const server = new Server(
15
+ { name: 'judicial-mcp', version },
16
+ { capabilities: { tools: {} } },
17
+ )
18
+
19
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
20
+ tools: Object.values(TOOLS_CONFIG),
21
+ }))
22
+
23
+ server.onerror = (error: unknown) => {
24
+ console.error('MCP 協議錯誤:', error instanceof Error ? error.message : error)
25
+ }
26
+
27
+ export async function dispatchTool(
28
+ name: string,
29
+ args: Record<string, unknown>,
30
+ ): Promise<McpToolResult> {
31
+ const handler = Object.prototype.hasOwnProperty.call(TOOL_HANDLERS, name)
32
+ ? TOOL_HANDLERS[name]
33
+ : undefined
34
+ if (!handler) {
35
+ throw new Error(`未知的工具: ${name}`)
36
+ }
37
+
38
+ const result = await handler(args)
39
+
40
+ if (
41
+ result !== null &&
42
+ typeof result === 'object' &&
43
+ 'content' in result &&
44
+ Array.isArray((result as { content: unknown }).content) &&
45
+ (result as { content: Array<{ type?: string }> }).content[0]?.type === 'resource'
46
+ ) {
47
+ return result as McpToolResult
48
+ }
49
+ return createSuccessResponse(result)
50
+ }
51
+
52
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
53
+ const { name, arguments: args } = request.params
54
+ try {
55
+ return await dispatchTool(name, (args ?? {}) as Record<string, unknown>)
56
+ } catch (error: unknown) {
57
+ console.error(`執行工具 ${name} 時發生錯誤:`, error instanceof Error ? error.message : error)
58
+ return createErrorResponse(error, `執行工具 ${name} 時發生錯誤`)
59
+ }
60
+ })
61
+
62
+ async function main() {
63
+ const transport = new StdioServerTransport()
64
+ await server.connect(transport)
65
+ }
66
+
67
+ main().catch((error: unknown) => {
68
+ console.error('伺服器啟動失敗:', error)
69
+ process.exit(1)
70
+ })