@tiangong-lca/mcp-server 0.0.28 → 0.0.31
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/README.md +9 -0
- package/dist/src/_shared/config.js +2 -0
- package/dist/src/_shared/init_server.js +2 -0
- package/dist/src/_shared/init_server_http.js +2 -0
- package/dist/src/tools/db_crud.js +89 -35
- package/dist/src/tools/glad_dataset_search.js +332 -0
- package/dist/src/tools/life_cycle_model_file_tools.js +857 -0
- package/package.json +18 -12
package/README.md
CHANGED
|
@@ -4,6 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
TianGong LCA Model Context Protocol (MCP) Server supports STDIO and Streamable Http protocols.
|
|
6
6
|
|
|
7
|
+
## Environment
|
|
8
|
+
|
|
9
|
+
GLAD dataset search tools require a GLAD API key in the server environment:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
GLAD_API_KEY=your-glad-api-key
|
|
13
|
+
GLAD_API_BASE_URL=https://www.globallcadataaccess.org/api/v1
|
|
14
|
+
```
|
|
15
|
+
|
|
7
16
|
## Starting MCP Server
|
|
8
17
|
|
|
9
18
|
### Client STDIO Server
|
|
@@ -10,3 +10,5 @@ export const supabase_publishable_key = process.env.SUPABASE_PUBLISHABLE_KEY ??
|
|
|
10
10
|
export const x_region = process.env.X_REGION ?? 'us-east-1';
|
|
11
11
|
export const redis_url = process.env.UPSTASH_REDIS_URL ?? '';
|
|
12
12
|
export const redis_token = process.env.UPSTASH_REDIS_TOKEN ?? '';
|
|
13
|
+
export const glad_api_base_url = process.env.GLAD_API_BASE_URL ?? 'https://www.globallcadataaccess.org/api/v1';
|
|
14
|
+
export const glad_api_key = process.env.GLAD_API_KEY ?? '';
|
|
@@ -2,6 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
2
2
|
import { regOpenLcaPrompts } from '../prompts/lca_calculation.js';
|
|
3
3
|
import { regOpenLcaResources } from '../resources/lca_calculation.js';
|
|
4
4
|
import { regFlowSearchTool } from '../tools/flow_hybrid_search.js';
|
|
5
|
+
import { regGladDatasetTools } from '../tools/glad_dataset_search.js';
|
|
5
6
|
import { regLcaCalculationGuidanceTool } from '../tools/lca_calculation_guidance.js';
|
|
6
7
|
import { regLifecycleModelSearchTool } from '../tools/life_cycle_model_hybrid_search.js';
|
|
7
8
|
import { regOpenLcaLciaTool } from '../tools/openlca_ipc_lcia.js';
|
|
@@ -16,6 +17,7 @@ export function initializeServer(bearerKey) {
|
|
|
16
17
|
regFlowSearchTool(server, bearerKey);
|
|
17
18
|
regProcessSearchTool(server, bearerKey);
|
|
18
19
|
regLifecycleModelSearchTool(server, bearerKey);
|
|
20
|
+
regGladDatasetTools(server);
|
|
19
21
|
regOpenLcaLciaTool(server);
|
|
20
22
|
regOpenLcaListSystemProcessesTool(server);
|
|
21
23
|
regOpenLcaListLCIAMethodsTool(server);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { regCrudTool } from '../tools/db_crud.js';
|
|
3
3
|
import { regFlowSearchTool } from '../tools/flow_hybrid_search.js';
|
|
4
|
+
import { regGladDatasetTools } from '../tools/glad_dataset_search.js';
|
|
4
5
|
import { regLifecycleModelSearchTool } from '../tools/life_cycle_model_hybrid_search.js';
|
|
5
6
|
import { regProcessSearchTool } from '../tools/process_hybrid_search.js';
|
|
6
7
|
export function initializeServer(bearerKey, supabaseSession) {
|
|
@@ -11,6 +12,7 @@ export function initializeServer(bearerKey, supabaseSession) {
|
|
|
11
12
|
regFlowSearchTool(server, bearerKey);
|
|
12
13
|
regProcessSearchTool(server, bearerKey);
|
|
13
14
|
regLifecycleModelSearchTool(server, bearerKey);
|
|
15
|
+
regGladDatasetTools(server);
|
|
14
16
|
regCrudTool(server, supabaseSession ?? bearerKey);
|
|
15
17
|
return server;
|
|
16
18
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { createClient
|
|
1
|
+
import { createClient } from '@supabase/supabase-js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { supabase_base_url, supabase_publishable_key } from '../_shared/config.js';
|
|
4
4
|
import { resolveSupabaseAccessToken } from '../_shared/supabase_session.js';
|
|
5
|
+
import { prepareLifecycleModelFile } from './life_cycle_model_file_tools.js';
|
|
5
6
|
const allowedTables = ['contacts', 'flows', 'lifecyclemodels', 'processes', 'sources'];
|
|
6
7
|
const tableSchema = z.enum(allowedTables);
|
|
7
|
-
const UPDATE_FUNCTION_NAME = 'update_data';
|
|
8
8
|
const MAX_VALIDATION_ERROR_LENGTH = 4_000;
|
|
9
9
|
const tablePrimaryKey = {
|
|
10
10
|
contacts: 'id',
|
|
@@ -50,7 +50,7 @@ const toolParamsSchema = {
|
|
|
50
50
|
jsonOrdered: z
|
|
51
51
|
.unknown()
|
|
52
52
|
.optional()
|
|
53
|
-
.describe('JSON value persisted into json_ordered (required for insert/update; omit for select/delete).'),
|
|
53
|
+
.describe('JSON value persisted into json_ordered (required for insert/update; omit for select/delete). For lifecyclemodels, native files, platform bundles, raw records, or a single-item array of those are accepted; json_tg and rule_verification are derived automatically before write.'),
|
|
54
54
|
};
|
|
55
55
|
const refinedInputSchema = z
|
|
56
56
|
.object(toolParamsSchema)
|
|
@@ -175,6 +175,51 @@ async function validateJsonOrdered(table, jsonOrdered) {
|
|
|
175
175
|
throw error;
|
|
176
176
|
}
|
|
177
177
|
}
|
|
178
|
+
function sanitizeLifecycleModelRows(rows) {
|
|
179
|
+
return rows.map((row) => {
|
|
180
|
+
const record = row && typeof row === 'object' && !Array.isArray(row)
|
|
181
|
+
? row
|
|
182
|
+
: {};
|
|
183
|
+
return {
|
|
184
|
+
id: record.id ?? null,
|
|
185
|
+
version: record.version ?? null,
|
|
186
|
+
json_ordered: record.json_ordered ?? null,
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
function sanitizeRowsForOutput(table, rows) {
|
|
191
|
+
return table === 'lifecyclemodels' ? sanitizeLifecycleModelRows(rows) : rows;
|
|
192
|
+
}
|
|
193
|
+
async function prepareWritePayload(table, jsonOrdered, inputId, inputVersion, bearerKey) {
|
|
194
|
+
if (table !== 'lifecyclemodels') {
|
|
195
|
+
await validateJsonOrdered(table, jsonOrdered);
|
|
196
|
+
return {
|
|
197
|
+
payload: {
|
|
198
|
+
json_ordered: jsonOrdered,
|
|
199
|
+
},
|
|
200
|
+
resolvedId: inputId,
|
|
201
|
+
resolvedVersion: inputVersion,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
const prepared = await prepareLifecycleModelFile({
|
|
205
|
+
payload: jsonOrdered,
|
|
206
|
+
}, bearerKey);
|
|
207
|
+
if (inputId && inputId !== prepared.lifecycleModelId) {
|
|
208
|
+
throw new Error(`Provided id (${inputId}) does not match lifecycle model UUID (${prepared.lifecycleModelId}).`);
|
|
209
|
+
}
|
|
210
|
+
if (inputVersion && inputVersion !== prepared.lifecycleModelVersion) {
|
|
211
|
+
throw new Error(`Provided version (${inputVersion}) does not match lifecycle model version (${prepared.lifecycleModelVersion}).`);
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
payload: {
|
|
215
|
+
json_ordered: prepared.jsonOrdered,
|
|
216
|
+
json_tg: prepared.jsonTg,
|
|
217
|
+
rule_verification: prepared.ruleVerification,
|
|
218
|
+
},
|
|
219
|
+
resolvedId: prepared.lifecycleModelId,
|
|
220
|
+
resolvedVersion: prepared.lifecycleModelVersion,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
178
223
|
async function createSupabaseClient(bearerKey) {
|
|
179
224
|
const { session: normalizedSession, accessToken: bearerToken } = resolveSupabaseAccessToken(bearerKey);
|
|
180
225
|
const supabase = createClient(supabase_base_url, supabase_publishable_key, {
|
|
@@ -206,7 +251,8 @@ async function createSupabaseClient(bearerKey) {
|
|
|
206
251
|
async function handleSelect(supabase, input) {
|
|
207
252
|
const { table, limit, id, version, filters } = input;
|
|
208
253
|
const keyColumn = getPrimaryKeyColumn(table);
|
|
209
|
-
|
|
254
|
+
const selectColumns = table === 'lifecyclemodels' ? 'id, version, json_ordered' : '*';
|
|
255
|
+
let queryBuilder = supabase.from(table).select(selectColumns);
|
|
210
256
|
if (filters) {
|
|
211
257
|
for (const [column, value] of Object.entries(filters)) {
|
|
212
258
|
if (value !== null && value !== undefined) {
|
|
@@ -228,10 +274,11 @@ async function handleSelect(supabase, input) {
|
|
|
228
274
|
console.error('Error querying the database:', error);
|
|
229
275
|
throw error;
|
|
230
276
|
}
|
|
231
|
-
|
|
277
|
+
const rows = sanitizeRowsForOutput(table, (data ?? []));
|
|
278
|
+
return JSON.stringify({ data: rows, count: rows.length });
|
|
232
279
|
}
|
|
233
|
-
async function handleInsert(supabase, input) {
|
|
234
|
-
const { table, jsonOrdered, id } = input;
|
|
280
|
+
async function handleInsert(supabase, input, bearerKey) {
|
|
281
|
+
const { table, jsonOrdered, id, version } = input;
|
|
235
282
|
if (jsonOrdered === undefined) {
|
|
236
283
|
throw new Error('jsonOrdered is required for insert operations.');
|
|
237
284
|
}
|
|
@@ -239,19 +286,28 @@ async function handleInsert(supabase, input) {
|
|
|
239
286
|
throw new Error('id is required for insert operations.');
|
|
240
287
|
}
|
|
241
288
|
const jsonOrderedValue = jsonOrdered;
|
|
242
|
-
await
|
|
289
|
+
const preparedWrite = await prepareWritePayload(table, jsonOrderedValue, id, version, bearerKey);
|
|
290
|
+
const resolvedId = preparedWrite.resolvedId ?? id;
|
|
291
|
+
const resolvedVersion = preparedWrite.resolvedVersion ?? version;
|
|
243
292
|
const keyColumn = getPrimaryKeyColumn(table);
|
|
244
293
|
const { data, error } = await supabase
|
|
245
294
|
.from(table)
|
|
246
|
-
.insert([
|
|
295
|
+
.insert([
|
|
296
|
+
{
|
|
297
|
+
[keyColumn]: resolvedId,
|
|
298
|
+
...(resolvedVersion !== undefined ? { version: resolvedVersion } : {}),
|
|
299
|
+
...preparedWrite.payload,
|
|
300
|
+
},
|
|
301
|
+
])
|
|
247
302
|
.select();
|
|
248
303
|
if (error) {
|
|
249
304
|
console.error('Error inserting into the database:', error);
|
|
250
305
|
throw error;
|
|
251
306
|
}
|
|
252
|
-
|
|
307
|
+
const rows = sanitizeRowsForOutput(table, (data ?? []));
|
|
308
|
+
return JSON.stringify({ id: resolvedId, version: resolvedVersion, data: rows });
|
|
253
309
|
}
|
|
254
|
-
async function handleUpdate(supabase, accessToken, input) {
|
|
310
|
+
async function handleUpdate(supabase, accessToken, input, bearerKey) {
|
|
255
311
|
const { table, id, version, jsonOrdered } = input;
|
|
256
312
|
if (id === undefined) {
|
|
257
313
|
throw new Error('id is required for update operations.');
|
|
@@ -263,29 +319,27 @@ async function handleUpdate(supabase, accessToken, input) {
|
|
|
263
319
|
throw new Error('jsonOrdered is required for update operations.');
|
|
264
320
|
}
|
|
265
321
|
const jsonOrderedValue = jsonOrdered;
|
|
266
|
-
await
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
322
|
+
const preparedWrite = await prepareWritePayload(table, jsonOrderedValue, id, version, bearerKey);
|
|
323
|
+
requireAccessToken(accessToken);
|
|
324
|
+
const keyColumn = getPrimaryKeyColumn(table);
|
|
325
|
+
const resolvedId = preparedWrite.resolvedId ?? id;
|
|
326
|
+
const resolvedVersion = preparedWrite.resolvedVersion ?? version;
|
|
327
|
+
const { data, error } = await supabase
|
|
328
|
+
.from(table)
|
|
329
|
+
.update(preparedWrite.payload)
|
|
330
|
+
.eq(keyColumn, resolvedId)
|
|
331
|
+
.eq('version', resolvedVersion)
|
|
332
|
+
.select();
|
|
275
333
|
if (error) {
|
|
276
|
-
console.error('Error
|
|
334
|
+
console.error('Error updating the database:', error);
|
|
277
335
|
throw error;
|
|
278
336
|
}
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
286
|
-
const keyColumn = getPrimaryKeyColumn(table);
|
|
287
|
-
const rows = ensureRows(updatedRows, `Update affected 0 rows for table "${table}"; verify the provided ${keyColumn} (${id}) and version (${version}) exist and are accessible.`);
|
|
288
|
-
return JSON.stringify({ id, version, data: rows });
|
|
337
|
+
const rows = ensureRows(data, `Update affected 0 rows for table "${table}"; verify the provided ${keyColumn} (${resolvedId}) and version (${resolvedVersion}) exist and are accessible.`);
|
|
338
|
+
return JSON.stringify({
|
|
339
|
+
id: resolvedId,
|
|
340
|
+
version: resolvedVersion,
|
|
341
|
+
data: sanitizeRowsForOutput(table, rows),
|
|
342
|
+
});
|
|
289
343
|
}
|
|
290
344
|
async function handleDelete(supabase, input) {
|
|
291
345
|
const { table, id, version } = input;
|
|
@@ -307,7 +361,7 @@ async function handleDelete(supabase, input) {
|
|
|
307
361
|
throw error;
|
|
308
362
|
}
|
|
309
363
|
const rows = ensureRows(data, `Delete affected 0 rows for table "${table}"; verify the provided ${keyColumn} (${id}) and version (${version}) exist and are accessible.`);
|
|
310
|
-
return JSON.stringify({ id, version, data: rows });
|
|
364
|
+
return JSON.stringify({ id, version, data: sanitizeRowsForOutput(table, rows) });
|
|
311
365
|
}
|
|
312
366
|
async function performCrud(input, bearerKey) {
|
|
313
367
|
try {
|
|
@@ -316,9 +370,9 @@ async function performCrud(input, bearerKey) {
|
|
|
316
370
|
case 'select':
|
|
317
371
|
return handleSelect(supabase, input);
|
|
318
372
|
case 'insert':
|
|
319
|
-
return handleInsert(supabase, input);
|
|
373
|
+
return handleInsert(supabase, input, bearerKey);
|
|
320
374
|
case 'update':
|
|
321
|
-
return handleUpdate(supabase, accessToken, input);
|
|
375
|
+
return handleUpdate(supabase, accessToken, input, bearerKey);
|
|
322
376
|
case 'delete':
|
|
323
377
|
return handleDelete(supabase, input);
|
|
324
378
|
default: {
|
|
@@ -333,7 +387,7 @@ async function performCrud(input, bearerKey) {
|
|
|
333
387
|
}
|
|
334
388
|
}
|
|
335
389
|
export function regCrudTool(server, bearerKey) {
|
|
336
|
-
server.tool('Database_CRUD_Tool', 'Perform select/insert/update/delete against allowed Supabase tables (insert needs jsonOrdered, update/delete need id and version).', toolParamsSchema, async (rawInput) => {
|
|
390
|
+
server.tool('Database_CRUD_Tool', 'Perform select/insert/update/delete against allowed Supabase tables (insert needs jsonOrdered, update/delete need id and version). lifecyclemodels insert/update automatically validate the payload, derive platform json_tg, compute rule_verification, and then write the row; lifecyclemodels select returns id/version/json_ordered only.', toolParamsSchema, async (rawInput) => {
|
|
337
391
|
const input = refinedInputSchema.parse(rawInput);
|
|
338
392
|
const result = await performCrud(input, bearerKey);
|
|
339
393
|
return {
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { glad_api_base_url, glad_api_key } from '../_shared/config.js';
|
|
3
|
+
const sectorSchema = z.enum([
|
|
4
|
+
'Agriculture',
|
|
5
|
+
'Chemicals',
|
|
6
|
+
'Construction',
|
|
7
|
+
'Electronics',
|
|
8
|
+
'Energy',
|
|
9
|
+
'Food',
|
|
10
|
+
'Metals',
|
|
11
|
+
'Mining',
|
|
12
|
+
'Textiles',
|
|
13
|
+
'Transport',
|
|
14
|
+
'Other',
|
|
15
|
+
]);
|
|
16
|
+
const formatSchema = z.enum(['ECOSPOLD1', 'ECOSPOLD2', 'ILCD', 'JSON-LD', 'OTHER', 'UNKNOWN']);
|
|
17
|
+
const processTypeSchema = z.enum([
|
|
18
|
+
'UNIT',
|
|
19
|
+
'PARTIALLY_AGGREGATED',
|
|
20
|
+
'FULLY_AGGREGATED',
|
|
21
|
+
'BRIDGE',
|
|
22
|
+
'UNKNOWN',
|
|
23
|
+
]);
|
|
24
|
+
const modelingTypeSchema = z.enum(['ATTRIBUTIONAL', 'CONSEQUENTIAL', 'BEFORE_MODELING', 'UNKNOWN']);
|
|
25
|
+
const reviewTypeSchema = z.enum(['INTERNAL', 'EXTERNAL', 'PANEL', 'UNKNOWN', 'NONE']);
|
|
26
|
+
const reviewSystemSchema = z.enum([
|
|
27
|
+
'ILCD',
|
|
28
|
+
'PEF',
|
|
29
|
+
'GHG',
|
|
30
|
+
'LCA_UN',
|
|
31
|
+
'OTHER',
|
|
32
|
+
'UNKNOWN',
|
|
33
|
+
'NOT_APPLICABLE',
|
|
34
|
+
]);
|
|
35
|
+
const extraFilterValueSchema = z.union([
|
|
36
|
+
z.string(),
|
|
37
|
+
z.number(),
|
|
38
|
+
z.boolean(),
|
|
39
|
+
z.array(z.union([z.string(), z.number(), z.boolean()])),
|
|
40
|
+
]);
|
|
41
|
+
const gladSearchInputSchema = z.object({
|
|
42
|
+
query: z
|
|
43
|
+
.string()
|
|
44
|
+
.min(1)
|
|
45
|
+
.optional()
|
|
46
|
+
.describe('Full-text search query. GLAD searches name, description, category, and technology; multiple words are combined with AND.'),
|
|
47
|
+
page: z.number().int().min(0).default(0).describe('Zero-based GLAD result page. Default: 0.'),
|
|
48
|
+
pageSize: z
|
|
49
|
+
.number()
|
|
50
|
+
.int()
|
|
51
|
+
.min(1)
|
|
52
|
+
.max(100)
|
|
53
|
+
.default(10)
|
|
54
|
+
.describe('Number of datasets to return. Capped at 100 to keep MCP responses usable.'),
|
|
55
|
+
sortBy: z.string().min(1).optional().describe('Field name to sort by, for example name.'),
|
|
56
|
+
sortOrder: z.enum(['ASC', 'DESC']).default('ASC').describe('Sort order used when sortBy is set.'),
|
|
57
|
+
sectors: z.array(sectorSchema).optional().describe('Filter by one or more GLAD sectors.'),
|
|
58
|
+
format: z.array(formatSchema).optional().describe('Filter by one or more dataset formats.'),
|
|
59
|
+
location: z
|
|
60
|
+
.array(z.string().min(1))
|
|
61
|
+
.optional()
|
|
62
|
+
.describe('Filter by one or more location codes, for example DE or ZA.'),
|
|
63
|
+
dataprovider: z
|
|
64
|
+
.array(z.string().min(1))
|
|
65
|
+
.optional()
|
|
66
|
+
.describe('Filter by one or more data provider names.'),
|
|
67
|
+
supportedNomenclatures: z
|
|
68
|
+
.array(z.string().min(1))
|
|
69
|
+
.optional()
|
|
70
|
+
.describe('Filter by supported nomenclature names, for example ecoinvent 3.5.'),
|
|
71
|
+
lciaMethods: z.array(z.string().min(1)).optional().describe('Filter by LCIA method names.'),
|
|
72
|
+
category: z
|
|
73
|
+
.array(z.string().min(1))
|
|
74
|
+
.optional()
|
|
75
|
+
.describe('Filter by category name or ISIC4 category code.'),
|
|
76
|
+
categoryPaths: z.array(z.string().min(1)).optional().describe('Filter by GLAD category paths.'),
|
|
77
|
+
unspscPaths: z.array(z.string().min(1)).optional().describe('Filter by UNSPSC path values.'),
|
|
78
|
+
co2pePaths: z.array(z.string().min(1)).optional().describe('Filter by CO2PE path values.'),
|
|
79
|
+
processType: z.array(processTypeSchema).optional().describe('Filter by GLAD process type.'),
|
|
80
|
+
modelingType: z.array(modelingTypeSchema).optional().describe('Filter by GLAD modeling type.'),
|
|
81
|
+
reviewType: z.array(reviewTypeSchema).optional().describe('Filter by GLAD review type.'),
|
|
82
|
+
reviewSystem: z.array(reviewSystemSchema).optional().describe('Filter by GLAD review system.'),
|
|
83
|
+
copyrightHolder: z.array(z.string().min(1)).optional().describe('Filter by copyright holder.'),
|
|
84
|
+
contact: z.string().min(1).optional().describe('Filter by contact value.'),
|
|
85
|
+
validFromYear: z.number().int().optional().describe('Filter by validity start year.'),
|
|
86
|
+
validUntilYear: z.number().int().optional().describe('Filter by validity end year.'),
|
|
87
|
+
copyrightProtected: z.boolean().optional().describe('Filter by copyright-protected status.'),
|
|
88
|
+
free: z.boolean().optional().describe('Filter by whether the dataset is available for free.'),
|
|
89
|
+
publiclyAccessible: z
|
|
90
|
+
.boolean()
|
|
91
|
+
.optional()
|
|
92
|
+
.describe('Filter by whether the dataset URL is publicly accessible without further login.'),
|
|
93
|
+
extraFilters: z
|
|
94
|
+
.record(z.string(), extraFilterValueSchema)
|
|
95
|
+
.optional()
|
|
96
|
+
.describe('Advanced GLAD query parameters not modeled explicitly. Use official parameter names; arrays are sent as repeated query parameters.'),
|
|
97
|
+
});
|
|
98
|
+
const gladGetDatasetInputSchema = z.object({
|
|
99
|
+
refId: z.string().min(1).describe('GLAD dataset refId.'),
|
|
100
|
+
dataProvider: z.string().min(1).describe('GLAD data provider name for the dataset.'),
|
|
101
|
+
});
|
|
102
|
+
const resultInfoSchema = z
|
|
103
|
+
.object({
|
|
104
|
+
currentPage: z.number().optional(),
|
|
105
|
+
pageSize: z.number().optional(),
|
|
106
|
+
pageCount: z.number().optional(),
|
|
107
|
+
count: z.number().optional(),
|
|
108
|
+
totalCount: z.number().optional(),
|
|
109
|
+
})
|
|
110
|
+
.passthrough();
|
|
111
|
+
const passthroughObjectSchema = z.object({}).passthrough();
|
|
112
|
+
const gladSearchOutputSchema = z
|
|
113
|
+
.object({
|
|
114
|
+
request: z
|
|
115
|
+
.object({
|
|
116
|
+
endpoint: z.string(),
|
|
117
|
+
page: z.number(),
|
|
118
|
+
pageSize: z.number(),
|
|
119
|
+
})
|
|
120
|
+
.passthrough(),
|
|
121
|
+
resultInfo: resultInfoSchema.optional(),
|
|
122
|
+
data: z.array(passthroughObjectSchema),
|
|
123
|
+
aggregations: z.array(passthroughObjectSchema),
|
|
124
|
+
})
|
|
125
|
+
.passthrough();
|
|
126
|
+
const gladGetDatasetOutputSchema = z
|
|
127
|
+
.object({
|
|
128
|
+
request: z
|
|
129
|
+
.object({
|
|
130
|
+
endpoint: z.string(),
|
|
131
|
+
refId: z.string(),
|
|
132
|
+
dataProvider: z.string(),
|
|
133
|
+
})
|
|
134
|
+
.passthrough(),
|
|
135
|
+
dataSet: passthroughObjectSchema,
|
|
136
|
+
})
|
|
137
|
+
.passthrough();
|
|
138
|
+
const ARRAY_PARAM_MAP = {
|
|
139
|
+
'sectors[]': 'sectors',
|
|
140
|
+
'format[]': 'format',
|
|
141
|
+
'location[]': 'location',
|
|
142
|
+
'dataprovider[]': 'dataprovider',
|
|
143
|
+
'supportedNomenclatures[]': 'supportedNomenclatures',
|
|
144
|
+
'lciaMethods[]': 'lciaMethods',
|
|
145
|
+
'category[]': 'category',
|
|
146
|
+
'categoryPaths[]': 'categoryPaths',
|
|
147
|
+
'unspscPaths[]': 'unspscPaths',
|
|
148
|
+
'co2pePaths[]': 'co2pePaths',
|
|
149
|
+
'processType[]': 'processType',
|
|
150
|
+
'modelingType[]': 'modelingType',
|
|
151
|
+
'reviewType[]': 'reviewType',
|
|
152
|
+
'reviewSystem[]': 'reviewSystem',
|
|
153
|
+
'copyrightHolder[]': 'copyrightHolder',
|
|
154
|
+
};
|
|
155
|
+
const SCALAR_PARAM_NAMES = [
|
|
156
|
+
'query',
|
|
157
|
+
'sortBy',
|
|
158
|
+
'sortOrder',
|
|
159
|
+
'page',
|
|
160
|
+
'pageSize',
|
|
161
|
+
'contact',
|
|
162
|
+
'validFromYear',
|
|
163
|
+
'validUntilYear',
|
|
164
|
+
'copyrightProtected',
|
|
165
|
+
'free',
|
|
166
|
+
'publiclyAccessible',
|
|
167
|
+
];
|
|
168
|
+
function createGladUrl(pathname) {
|
|
169
|
+
const baseUrl = glad_api_base_url.replace(/\/+$/, '');
|
|
170
|
+
return new URL(`${baseUrl}${pathname}`);
|
|
171
|
+
}
|
|
172
|
+
function appendParam(params, key, value) {
|
|
173
|
+
if (value === undefined || value === null || value === '') {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (Array.isArray(value)) {
|
|
177
|
+
for (const item of value) {
|
|
178
|
+
appendParam(params, key, item);
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
params.append(key, String(value));
|
|
183
|
+
}
|
|
184
|
+
function appendSearchParams(url, input) {
|
|
185
|
+
for (const key of SCALAR_PARAM_NAMES) {
|
|
186
|
+
appendParam(url.searchParams, key, input[key]);
|
|
187
|
+
}
|
|
188
|
+
for (const [wireName, inputKey] of Object.entries(ARRAY_PARAM_MAP)) {
|
|
189
|
+
appendParam(url.searchParams, wireName, input[inputKey]);
|
|
190
|
+
}
|
|
191
|
+
if (input.extraFilters) {
|
|
192
|
+
for (const [key, value] of Object.entries(input.extraFilters)) {
|
|
193
|
+
appendParam(url.searchParams, key, value);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function assertGladApiKey() {
|
|
198
|
+
if (!glad_api_key) {
|
|
199
|
+
throw new Error('Missing GLAD_API_KEY. Add it to the MCP server environment before using GLAD tools.');
|
|
200
|
+
}
|
|
201
|
+
return glad_api_key;
|
|
202
|
+
}
|
|
203
|
+
function buildGladErrorMessage(status, statusText, body) {
|
|
204
|
+
const trimmedBody = body.trim();
|
|
205
|
+
if (trimmedBody.startsWith('<!DOCTYPE html') || trimmedBody.startsWith('<html')) {
|
|
206
|
+
return `GLAD API returned an HTML page instead of JSON (HTTP ${status} ${statusText}). This usually means Cloudflare or browser verification blocked this Node.js runtime before the GLAD API processed the api-key. Verify this runtime can reach GLAD directly, or point GLAD_API_BASE_URL at an accessible GLAD-compatible API endpoint.`;
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
const parsed = JSON.parse(trimmedBody);
|
|
210
|
+
if (typeof parsed === 'object' && parsed !== null && 'message' in parsed) {
|
|
211
|
+
return `GLAD API request failed (HTTP ${status} ${statusText}): ${String(parsed.message)}`;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
}
|
|
216
|
+
const preview = trimmedBody.slice(0, 500);
|
|
217
|
+
return `GLAD API request failed (HTTP ${status} ${statusText})${preview ? `: ${preview}` : ''}`;
|
|
218
|
+
}
|
|
219
|
+
async function requestGladJson(url) {
|
|
220
|
+
const response = await fetch(url, {
|
|
221
|
+
method: 'GET',
|
|
222
|
+
headers: {
|
|
223
|
+
Accept: 'application/json',
|
|
224
|
+
'User-Agent': 'TianGong-LCA-MCP/1.0',
|
|
225
|
+
'api-key': assertGladApiKey(),
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
const text = await response.text();
|
|
229
|
+
if (!response.ok) {
|
|
230
|
+
throw new Error(buildGladErrorMessage(response.status, response.statusText, text));
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
return JSON.parse(text);
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
throw new Error(`GLAD API returned a non-JSON success response from ${url.pathname}: ${error.message}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async function searchGladDatasets(input) {
|
|
240
|
+
const url = createGladUrl('/search');
|
|
241
|
+
appendSearchParams(url, input);
|
|
242
|
+
const result = await requestGladJson(url);
|
|
243
|
+
return {
|
|
244
|
+
request: {
|
|
245
|
+
endpoint: `${url.origin}${url.pathname}`,
|
|
246
|
+
page: input.page,
|
|
247
|
+
pageSize: input.pageSize,
|
|
248
|
+
},
|
|
249
|
+
resultInfo: result.resultInfo,
|
|
250
|
+
data: result.data ?? [],
|
|
251
|
+
aggregations: result.aggregations ?? [],
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
async function getGladDataset(input) {
|
|
255
|
+
const url = createGladUrl(`/search/index/${encodeURIComponent(input.refId)}/${encodeURIComponent(input.dataProvider)}`);
|
|
256
|
+
const dataSet = await requestGladJson(url);
|
|
257
|
+
return {
|
|
258
|
+
request: {
|
|
259
|
+
endpoint: `${url.origin}${url.pathname}`,
|
|
260
|
+
refId: input.refId,
|
|
261
|
+
dataProvider: input.dataProvider,
|
|
262
|
+
},
|
|
263
|
+
dataSet,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function toSuccessResult(output) {
|
|
267
|
+
return {
|
|
268
|
+
content: [
|
|
269
|
+
{
|
|
270
|
+
type: 'text',
|
|
271
|
+
text: JSON.stringify(output, null, 2),
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
structuredContent: output,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
function toErrorResult(error) {
|
|
278
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
279
|
+
return {
|
|
280
|
+
isError: true,
|
|
281
|
+
content: [
|
|
282
|
+
{
|
|
283
|
+
type: 'text',
|
|
284
|
+
text: message,
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
export function regGladDatasetTools(server) {
|
|
290
|
+
server.registerTool('Search_GLAD_Datasets_Tool', {
|
|
291
|
+
title: 'Search GLAD Datasets',
|
|
292
|
+
description: 'Search Global LCA Data Access (GLAD) dataset descriptors through the official GLAD /search API. Requires GLAD_API_KEY in the MCP server environment.',
|
|
293
|
+
inputSchema: gladSearchInputSchema,
|
|
294
|
+
outputSchema: gladSearchOutputSchema,
|
|
295
|
+
annotations: {
|
|
296
|
+
readOnlyHint: true,
|
|
297
|
+
destructiveHint: false,
|
|
298
|
+
idempotentHint: true,
|
|
299
|
+
openWorldHint: true,
|
|
300
|
+
},
|
|
301
|
+
}, async (rawInput) => {
|
|
302
|
+
try {
|
|
303
|
+
const input = gladSearchInputSchema.parse(rawInput);
|
|
304
|
+
const output = await searchGladDatasets(input);
|
|
305
|
+
return toSuccessResult(output);
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
return toErrorResult(error);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
server.registerTool('Get_GLAD_Dataset_Tool', {
|
|
312
|
+
title: 'Get GLAD Dataset',
|
|
313
|
+
description: 'Fetch one GLAD dataset descriptor by refId and dataProvider through the official GLAD /search/index/{refId}/{dataProvider} API. Requires GLAD_API_KEY in the MCP server environment.',
|
|
314
|
+
inputSchema: gladGetDatasetInputSchema,
|
|
315
|
+
outputSchema: gladGetDatasetOutputSchema,
|
|
316
|
+
annotations: {
|
|
317
|
+
readOnlyHint: true,
|
|
318
|
+
destructiveHint: false,
|
|
319
|
+
idempotentHint: true,
|
|
320
|
+
openWorldHint: true,
|
|
321
|
+
},
|
|
322
|
+
}, async (rawInput) => {
|
|
323
|
+
try {
|
|
324
|
+
const input = gladGetDatasetInputSchema.parse(rawInput);
|
|
325
|
+
const output = await getGladDataset(input);
|
|
326
|
+
return toSuccessResult(output);
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
return toErrorResult(error);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
}
|