indyiq-pi-mcp 1.0.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.

Potentially problematic release.


This version of indyiq-pi-mcp might be problematic. Click here for more details.

package/.env.example ADDED
@@ -0,0 +1,3 @@
1
+ PI_BASE_URL=https://<your-pi-server>/piwebapi
2
+ PI_USER=<username>
3
+ PI_PASSWORD=<password>
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # indyiq-pi-mcp
2
+
3
+ An [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that exposes OSIsoft PI Web API as read-only tools for Claude. Runs as a local stdio process — no separate server needed. Each user gets their own instance spawned by Claude.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install -g indyiq-pi-mcp
9
+ ```
10
+
11
+ ## Connecting to Claude
12
+
13
+ Credentials are passed via the `env` block in your MCP config.
14
+
15
+ ### Claude Code (`~/.claude/settings.json`)
16
+
17
+ ```json
18
+ {
19
+ "mcpServers": {
20
+ "pi": {
21
+ "type": "stdio",
22
+ "command": "indyiq-pi-mcp",
23
+ "env": {
24
+ "PI_BASE_URL": "https://<your-pi-server>/piwebapi",
25
+ "PI_USER": "<username>",
26
+ "PI_PASSWORD": "<password>"
27
+ }
28
+ }
29
+ }
30
+ }
31
+ ```
32
+
33
+ ### Claude Desktop (`claude_desktop_config.json`)
34
+
35
+ ```json
36
+ {
37
+ "mcpServers": {
38
+ "pi": {
39
+ "command": "indyiq-pi-mcp",
40
+ "env": {
41
+ "PI_BASE_URL": "https://<your-pi-server>/piwebapi",
42
+ "PI_USER": "<username>",
43
+ "PI_PASSWORD": "<password>"
44
+ }
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
50
+ ## Local development
51
+
52
+ ```bash
53
+ git clone https://github.com/indyiq/indyiq-pi-mcp
54
+ cd indyiq-pi-mcp
55
+ npm install
56
+ cp .env.example .env # fill in credentials
57
+ node server.js
58
+ ```
59
+
60
+ ## Available Tools
61
+
62
+ All tools are **read-only** — no writes to PI Web API.
63
+
64
+ | Category | Tools |
65
+ |----------|-------|
66
+ | **System** | `piwebapi_home`, `piwebapi_system_status`, `piwebapi_system_versions` |
67
+ | **Cache** | `piwebapi_cache`, `piwebapi_cache_find` |
68
+ | **Servers** | `piwebapi_list_asset_servers`, `piwebapi_list_data_servers` |
69
+ | **Asset Databases** | `piwebapi_list_databases`, `piwebapi_get_database` |
70
+ | **Elements** | `piwebapi_get_element`, `piwebapi_list_elements`, `piwebapi_search_elements` |
71
+ | **Attributes** | `piwebapi_get_element_attributes`, `piwebapi_get_attribute`, `piwebapi_get_attribute_value` |
72
+ | **Templates** | `piwebapi_list_element_templates` |
73
+ | **PI Points** | `piwebapi_get_point`, `piwebapi_list_data_server_points`, `piwebapi_search_points` |
74
+ | **Stream Data** | `piwebapi_get_stream_end`, `piwebapi_get_stream_recorded`, `piwebapi_get_stream_interpolated`, `piwebapi_get_stream_summary`, `piwebapi_get_stream_plot` |
75
+ | **Bulk Stream** | `piwebapi_streamsets_end`, `piwebapi_streamsets_recorded`, `piwebapi_streamsets_interpolated`, `piwebapi_streamsets_summary` |
76
+ | **Event Frames** | `piwebapi_search_event_frames`, `piwebapi_get_event_frame`, `piwebapi_get_event_frame_attributes`, `piwebapi_list_element_event_frames` |
77
+ | **Batch** | `piwebapi_batch` |
78
+
79
+ ## Local Cache
80
+
81
+ On startup the server builds a SQLite cache (`cache.db`) of servers, databases, elements, attributes, points, and recent event frames (last 30 days). Use the `piwebapi_cache` and `piwebapi_cache_find` tools to inspect it.
82
+
83
+ ## Architecture
84
+
85
+ | File | Purpose |
86
+ |------|---------|
87
+ | `server.js` | Stdio entry point — connects MCP server to Claude via stdin/stdout |
88
+ | `tools.js` | All 33 MCP tool definitions, PI Web API client, cache, and markdown formatter |
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "indyiq-pi-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server exposing OSIsoft PI Web API as read-only tools for Claude",
5
+ "main": "server.js",
6
+ "bin": {
7
+ "indyiq-pi-mcp": "server.js"
8
+ },
9
+ "files": [
10
+ "server.js",
11
+ "tools.js",
12
+ ".env.example"
13
+ ],
14
+ "type": "commonjs",
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "scripts": {
19
+ "start": "node server.js"
20
+ },
21
+ "keywords": [
22
+ "mcp",
23
+ "pi",
24
+ "piwebapi",
25
+ "osisoft",
26
+ "claude"
27
+ ],
28
+ "license": "MIT",
29
+ "dependencies": {
30
+ "@modelcontextprotocol/sdk": "^1.28.0",
31
+ "axios": "^1.7.0",
32
+ "better-sqlite3": "^12.8.0",
33
+ "dotenv": "^17.3.1",
34
+ "zod": "^3.23.0"
35
+ }
36
+ }
package/server.js ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
5
+ const { createMcpServer } = require('./tools.js');
6
+
7
+ async function main() {
8
+ const server = createMcpServer();
9
+ const transport = new StdioServerTransport();
10
+ await server.connect(transport);
11
+ }
12
+
13
+ main().catch(err => {
14
+ process.stderr.write(`Fatal: ${err.message}\n`);
15
+ process.exit(1);
16
+ });
package/tools.js ADDED
@@ -0,0 +1,951 @@
1
+ 'use strict';
2
+
3
+ require('dotenv').config();
4
+
5
+ const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
6
+ const { z } = require('zod');
7
+ const axios = require('axios');
8
+ const https = require('https');
9
+
10
+ // ── Config ────────────────────────────────────────────────────────────────────
11
+ const PI_BASE_URL = process.env.PI_BASE_URL;
12
+ const PI_USER = process.env.PI_USER;
13
+ const PI_PASSWORD = process.env.PI_PASSWORD;
14
+
15
+ // ── HTTP client ───────────────────────────────────────────────────────────────
16
+ const piClient = axios.create({
17
+ baseURL: PI_BASE_URL,
18
+ auth: { username: PI_USER, password: PI_PASSWORD },
19
+ httpsAgent: new https.Agent({ rejectUnauthorized: false }),
20
+ headers: { Accept: 'application/json' },
21
+ timeout: 30000,
22
+ });
23
+
24
+ async function piGet(path, params = {}) {
25
+ const cleanParams = Object.fromEntries(
26
+ Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== '')
27
+ );
28
+ const res = await piClient.get(path, { params: cleanParams });
29
+ return res.data;
30
+ }
31
+
32
+ async function piPost(path, body, params = {}) {
33
+ const cleanParams = Object.fromEntries(
34
+ Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== '')
35
+ );
36
+ const res = await piClient.post(path, body, { params: cleanParams });
37
+ return res.data;
38
+ }
39
+
40
+ // ── Cache ─────────────────────────────────────────────────────────────────────
41
+
42
+ const Database = require('better-sqlite3');
43
+ const path = require('path');
44
+
45
+ const db = new Database(path.join(__dirname, 'cache.db'));
46
+
47
+ db.exec(`
48
+ CREATE TABLE IF NOT EXISTS data_servers (
49
+ WebId TEXT PRIMARY KEY, Name TEXT, Path TEXT, IsConnected INTEGER, ServerVersion TEXT, last_seen TEXT
50
+ );
51
+ CREATE TABLE IF NOT EXISTS asset_servers (
52
+ WebId TEXT PRIMARY KEY, Name TEXT, Path TEXT, IsConnected INTEGER, last_seen TEXT
53
+ );
54
+ CREATE TABLE IF NOT EXISTS databases (
55
+ WebId TEXT PRIMARY KEY, Name TEXT, Path TEXT, Description TEXT, ServerName TEXT, last_seen TEXT
56
+ );
57
+ CREATE TABLE IF NOT EXISTS points (
58
+ WebId TEXT PRIMARY KEY, Name TEXT, Path TEXT, PointType TEXT,
59
+ EngineeringUnits TEXT, Description TEXT, ServerName TEXT, last_seen TEXT
60
+ );
61
+ CREATE TABLE IF NOT EXISTS elements (
62
+ WebId TEXT PRIMARY KEY, Name TEXT, Path TEXT, Description TEXT,
63
+ TemplateName TEXT, DatabaseName TEXT, ServerName TEXT, last_seen TEXT
64
+ );
65
+ CREATE TABLE IF NOT EXISTS attributes (
66
+ WebId TEXT PRIMARY KEY, Name TEXT, Path TEXT, Description TEXT,
67
+ Type TEXT, EngineeringUnits TEXT, ElementWebId TEXT, ElementName TEXT,
68
+ DatabaseName TEXT, ServerName TEXT, last_seen TEXT
69
+ );
70
+ CREATE TABLE IF NOT EXISTS event_frames (
71
+ WebId TEXT PRIMARY KEY, Name TEXT, Path TEXT, Description TEXT,
72
+ TemplateName TEXT, StartTime TEXT, EndTime TEXT, DatabaseName TEXT, ServerName TEXT, last_seen TEXT
73
+ );
74
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
75
+ `);
76
+
77
+ // Add last_seen to existing databases that were created before this column existed
78
+ for (const table of ['data_servers','asset_servers','databases','points','elements','attributes','event_frames']) {
79
+ try { db.prepare(`ALTER TABLE ${table} ADD COLUMN last_seen TEXT`).run(); } catch {}
80
+ }
81
+
82
+ const NOW = () => new Date().toISOString();
83
+
84
+ const upsertDataServer = db.prepare(`INSERT OR REPLACE INTO data_servers VALUES (@WebId,@Name,@Path,@IsConnected,@ServerVersion,@last_seen)`);
85
+ const upsertAssetServer = db.prepare(`INSERT OR REPLACE INTO asset_servers VALUES (@WebId,@Name,@Path,@IsConnected,@last_seen)`);
86
+ const upsertDatabase = db.prepare(`INSERT OR REPLACE INTO databases VALUES (@WebId,@Name,@Path,@Description,@ServerName,@last_seen)`);
87
+ const upsertPoint = db.prepare(`INSERT OR REPLACE INTO points VALUES (@WebId,@Name,@Path,@PointType,@EngineeringUnits,@Description,@ServerName,@last_seen)`);
88
+ const upsertElement = db.prepare(`INSERT OR REPLACE INTO elements VALUES (@WebId,@Name,@Path,@Description,@TemplateName,@DatabaseName,@ServerName,@last_seen)`);
89
+ const upsertAttribute = db.prepare(`INSERT OR REPLACE INTO attributes VALUES (@WebId,@Name,@Path,@Description,@Type,@EngineeringUnits,@ElementWebId,@ElementName,@DatabaseName,@ServerName,@last_seen)`);
90
+ const upsertEventFrame = db.prepare(`INSERT OR REPLACE INTO event_frames VALUES (@WebId,@Name,@Path,@Description,@TemplateName,@StartTime,@EndTime,@DatabaseName,@ServerName,@last_seen)`);
91
+
92
+ const cache = {
93
+ updatedAt: db.prepare(`SELECT value FROM meta WHERE key='updatedAt'`).get()?.value ?? null,
94
+ dataServers: db.prepare(`SELECT * FROM data_servers`).all(),
95
+ assetServers: db.prepare(`SELECT * FROM asset_servers`).all(),
96
+ databases: db.prepare(`SELECT * FROM databases`).all(),
97
+ points: db.prepare(`SELECT * FROM points`).all(),
98
+ elements: db.prepare(`SELECT * FROM elements`).all(),
99
+ attributes: db.prepare(`SELECT * FROM attributes`).all(),
100
+ eventFrames: db.prepare(`SELECT * FROM event_frames`).all(),
101
+ };
102
+
103
+ // Fetch all pages of a paginated PI Web API endpoint
104
+ async function piGetAll(urlPath, params = {}, pageSize = 1000) {
105
+ const items = [];
106
+ let startIndex = 0;
107
+ while (true) {
108
+ const res = await piGet(urlPath, { ...params, maxCount: pageSize, startIndex });
109
+ const page = res?.Items ?? [];
110
+ items.push(...page);
111
+ if (page.length < pageSize) break;
112
+ startIndex += pageSize;
113
+ }
114
+ return items;
115
+ }
116
+
117
+ // Run async tasks with limited concurrency
118
+ async function withConcurrency(items, concurrency, fn) {
119
+ const results = [];
120
+ for (let i = 0; i < items.length; i += concurrency) {
121
+ const batch = items.slice(i, i + concurrency);
122
+ const batchResults = await Promise.all(batch.map(fn));
123
+ results.push(...batchResults);
124
+ }
125
+ return results;
126
+ }
127
+
128
+ async function refreshCache() {
129
+ try {
130
+ console.log('[cache] starting refresh...');
131
+
132
+ // ── Servers ──
133
+ const [dsRes, asRes] = await Promise.all([
134
+ piGet('/dataservers'),
135
+ piGet('/assetservers'),
136
+ ]);
137
+ cache.dataServers = dsRes?.Items ?? [];
138
+ cache.assetServers = asRes?.Items ?? [];
139
+ console.log(`[cache] ${cache.dataServers.length} data servers, ${cache.assetServers.length} AF servers`);
140
+
141
+ // ── Databases ──
142
+ const dbResults = await Promise.all(
143
+ cache.assetServers.map(s =>
144
+ piGet(`/assetservers/${encodeURIComponent(s.WebId)}/assetdatabases`)
145
+ .then(r => (r?.Items ?? []).map(d => ({ ...d, ServerName: s.Name })))
146
+ .catch(() => [])
147
+ )
148
+ );
149
+ cache.databases = dbResults.flat();
150
+ console.log(`[cache] ${cache.databases.length} databases`);
151
+
152
+ // ── Points ──
153
+ const ptResults = await Promise.all(
154
+ cache.dataServers.map(s =>
155
+ piGetAll(`/dataservers/${encodeURIComponent(s.WebId)}/points`)
156
+ .then(items => items.map(pt => ({ ...pt, ServerName: s.Name })))
157
+ .catch(() => [])
158
+ )
159
+ );
160
+ cache.points = ptResults.flat();
161
+ console.log(`[cache] ${cache.points.length} points`);
162
+
163
+ // ── Elements (all descendants, paginated, per database) ──
164
+ const elResults = await withConcurrency(cache.databases, 3, async (database) => {
165
+ const items = await piGetAll(
166
+ `/assetdatabases/${encodeURIComponent(database.WebId)}/elements`,
167
+ { searchFullHierarchy: true }
168
+ ).catch(() => []);
169
+ return items.map(e => ({ ...e, DatabaseName: database.Name, ServerName: database.ServerName }));
170
+ });
171
+ cache.elements = elResults.flat();
172
+ console.log(`[cache] ${cache.elements.length} elements`);
173
+
174
+ // ── Attributes (per element, batched 5 at a time) ──
175
+ console.log(`[cache] fetching attributes for ${cache.elements.length} elements...`);
176
+ const atResults = await withConcurrency(cache.elements, 5, async (el) => {
177
+ const items = await piGetAll(
178
+ `/elements/${encodeURIComponent(el.WebId)}/attributes`,
179
+ { selectedFields: 'Items.WebId;Items.Name;Items.Path;Items.Description;Items.Type;Items.DefaultUnitsNameAbbreviation' }
180
+ ).catch(() => []);
181
+ return items.map(a => ({
182
+ ...a,
183
+ EngineeringUnits: a.DefaultUnitsNameAbbreviation ?? null,
184
+ ElementWebId: el.WebId,
185
+ ElementName: el.Name,
186
+ DatabaseName: el.DatabaseName,
187
+ ServerName: el.ServerName,
188
+ }));
189
+ });
190
+ cache.attributes = atResults.flat();
191
+ console.log(`[cache] ${cache.attributes.length} attributes`);
192
+
193
+ // ── Event Frames (last 30 days, per database, upsert by WebId) ──
194
+ const efResults = await withConcurrency(cache.databases, 3, async (database) => {
195
+ const items = await piGetAll(
196
+ `/assetdatabases/${encodeURIComponent(database.WebId)}/eventframes`,
197
+ { startTime: '*-30d', selectedFields: 'Items.WebId;Items.Name;Items.Path;Items.Description;Items.TemplateName;Items.StartTime;Items.EndTime' }
198
+ ).catch(() => []);
199
+ return items.map(e => ({ ...e, DatabaseName: database.Name, ServerName: database.ServerName }));
200
+ });
201
+ cache.eventFrames = efResults.flat();
202
+ console.log(`[cache] ${cache.eventFrames.length} event frames`);
203
+
204
+ cache.updatedAt = new Date().toISOString();
205
+
206
+ // ── Persist to SQLite (upsert by WebId — no DELETE, updates in place) ──
207
+ console.log('[cache] persisting to SQLite...');
208
+ const now = NOW();
209
+ const persist = db.transaction(() => {
210
+ for (const r of cache.dataServers) upsertDataServer.run({ WebId: r.WebId, Name: r.Name, Path: r.Path, IsConnected: r.IsConnected ? 1 : 0, ServerVersion: r.ServerVersion ?? null, last_seen: now });
211
+ for (const r of cache.assetServers) upsertAssetServer.run({ WebId: r.WebId, Name: r.Name, Path: r.Path, IsConnected: r.IsConnected ? 1 : 0, last_seen: now });
212
+ for (const r of cache.databases) upsertDatabase.run({ WebId: r.WebId, Name: r.Name, Path: r.Path, Description: r.Description ?? null, ServerName: r.ServerName, last_seen: now });
213
+ for (const r of cache.points) upsertPoint.run({ WebId: r.WebId, Name: r.Name, Path: r.Path, PointType: r.PointType ?? null, EngineeringUnits: r.EngineeringUnits ?? null, Description: r.Description ?? null, ServerName: r.ServerName, last_seen: now });
214
+ for (const r of cache.elements) upsertElement.run({ WebId: r.WebId, Name: r.Name, Path: r.Path, Description: r.Description ?? null, TemplateName: r.TemplateName ?? null, DatabaseName: r.DatabaseName, ServerName: r.ServerName, last_seen: now });
215
+ for (const r of cache.attributes) upsertAttribute.run({ WebId: r.WebId, Name: r.Name, Path: r.Path, Description: r.Description ?? null, Type: r.Type ?? null, EngineeringUnits: r.EngineeringUnits ?? null, ElementWebId: r.ElementWebId, ElementName: r.ElementName, DatabaseName: r.DatabaseName, ServerName: r.ServerName, last_seen: now });
216
+ for (const r of cache.eventFrames) upsertEventFrame.run({ WebId: r.WebId, Name: r.Name, Path: r.Path ?? null, Description: r.Description ?? null, TemplateName: r.TemplateName ?? null, StartTime: r.StartTime ?? null, EndTime: r.EndTime ?? null, DatabaseName: r.DatabaseName, ServerName: r.ServerName, last_seen: now });
217
+ db.prepare(`INSERT OR REPLACE INTO meta VALUES ('updatedAt', ?)`).run(cache.updatedAt);
218
+ });
219
+ persist();
220
+
221
+ console.log(`[cache] done — ${cache.dataServers.length} data servers, ${cache.assetServers.length} AF servers, ${cache.databases.length} databases, ${cache.points.length} points, ${cache.elements.length} elements, ${cache.attributes.length} attributes, ${cache.eventFrames.length} event frames`);
222
+ } catch (err) {
223
+ console.error('[cache] refresh error:', err.message);
224
+ }
225
+ }
226
+
227
+ function flushStaleData() {
228
+ const tables = ['data_servers','asset_servers','databases','points','elements','attributes','event_frames'];
229
+ const result = db.transaction(() => {
230
+ let total = 0;
231
+ for (const table of tables) {
232
+ const { changes } = db.prepare(
233
+ `DELETE FROM ${table} WHERE last_seen < datetime('now', '-20 days')`
234
+ ).run();
235
+ total += changes;
236
+ }
237
+ return total;
238
+ })();
239
+ if (result > 0) console.log(`[cache] flushed ${result} stale rows (older than 20 days)`);
240
+ }
241
+
242
+ // Refresh immediately on startup, then every 2 minutes
243
+ refreshCache();
244
+ setInterval(refreshCache, 2 * 60 * 1000);
245
+
246
+ // Flush stale data on startup and once per day
247
+ flushStaleData();
248
+ setInterval(flushStaleData, 24 * 60 * 60 * 1000);
249
+
250
+ // ── SQLite cache queries ──────────────────────────────────────────────────────
251
+
252
+ const sql = {
253
+ assetServers: db.prepare(`SELECT * FROM asset_servers`),
254
+ dataServers: db.prepare(`SELECT * FROM data_servers`),
255
+ dbsByServer: db.prepare(`SELECT * FROM databases WHERE ServerName = ?`),
256
+ dbByWebId: db.prepare(`SELECT * FROM databases WHERE WebId = ?`),
257
+ dbByPath: db.prepare(`SELECT * FROM databases WHERE Path = ?`),
258
+ elementByWebId: db.prepare(`SELECT * FROM elements WHERE WebId = ?`),
259
+ elementByPath: db.prepare(`SELECT * FROM elements WHERE Path = ?`),
260
+ elementsByDb: db.prepare(`SELECT * FROM elements WHERE DatabaseName = ?`),
261
+ elementsByParentPath: db.prepare(`SELECT * FROM elements WHERE Path LIKE ? AND Path NOT LIKE ?`),
262
+ attrByWebId: db.prepare(`SELECT * FROM attributes WHERE WebId = ?`),
263
+ attrsByElement: db.prepare(`SELECT * FROM attributes WHERE ElementWebId = ?`),
264
+ pointByWebId: db.prepare(`SELECT * FROM points WHERE WebId = ?`),
265
+ pointByPath: db.prepare(`SELECT * FROM points WHERE Path = ?`),
266
+ pointsByServer: db.prepare(`SELECT * FROM points WHERE ServerName = ?`),
267
+ hasRows: (table) => db.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get(),
268
+ };
269
+
270
+ // ── Output formatter ─────────────────────────────────────────────────────────
271
+
272
+ function respond(data) {
273
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
274
+ }
275
+
276
+ function respondCached(data) {
277
+ const note = `\n\n// cache last updated: ${cache.updatedAt ?? 'unknown'}`;
278
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) + note }] };
279
+ }
280
+
281
+ // ── MCP Server factory ────────────────────────────────────────────────────────
282
+ function createMcpServer() {
283
+ const server = new McpServer({
284
+ name: 'piwebapi-mcp',
285
+ version: '1.0.0',
286
+ });
287
+
288
+ server.tool(
289
+ 'piwebapi_home',
290
+ 'Get the PI Web API root landing page with links to all top-level resources (asset servers, data servers, system info, etc.)',
291
+ {},
292
+ async () => {
293
+ const data = await piGet('/');
294
+ return respond(data);
295
+ }
296
+ );
297
+
298
+ server.tool(
299
+ 'piwebapi_cache',
300
+ 'Return a summary of the cached PI Web API inventory: servers, databases, and point count with their WebIds. Refreshed every 2 minutes.',
301
+ {},
302
+ async () => {
303
+ const summary = {
304
+ updatedAt: cache.updatedAt,
305
+ dataServers: cache.dataServers.map(r => ({ Name: r.Name, WebId: r.WebId, Path: r.Path })),
306
+ assetServers: cache.assetServers.map(r => ({ Name: r.Name, WebId: r.WebId, Path: r.Path })),
307
+ databases: cache.databases.map(r => ({ Name: r.Name, WebId: r.WebId, ServerName: r.ServerName })),
308
+ pointCount: cache.points.length,
309
+ elementCount: cache.elements.length,
310
+ attributeCount: cache.attributes.length,
311
+ eventFrameCount: cache.eventFrames.length,
312
+ };
313
+ return respondCached(summary);
314
+ }
315
+ );
316
+
317
+ server.tool(
318
+ 'piwebapi_cache_find',
319
+ 'Search the cached inventory by name. Returns matching Name, WebId, Path. Use this to look up WebIds before making API calls.',
320
+ {
321
+ name: z.string().describe('Name or partial name to search (case-insensitive)'),
322
+ type: z.enum(['points', 'elements', 'attributes', 'databases', 'eventframes', 'all']).optional().describe('What to search (default: all)'),
323
+ },
324
+ async ({ name, type = 'all' }) => {
325
+ const pattern = name.toLowerCase();
326
+ const match = item => item.Name?.toLowerCase().includes(pattern);
327
+ const results = {};
328
+ if (type === 'points' || type === 'all') results.points = cache.points.filter(match).map(r => ({ Name: r.Name, WebId: r.WebId, Path: r.Path, PointType: r.PointType, ServerName: r.ServerName }));
329
+ if (type === 'elements' || type === 'all') results.elements = cache.elements.filter(match).map(r => ({ Name: r.Name, WebId: r.WebId, Path: r.Path, TemplateName: r.TemplateName, DatabaseName: r.DatabaseName }));
330
+ if (type === 'attributes' || type === 'all') results.attributes = cache.attributes.filter(match).map(r => ({ Name: r.Name, WebId: r.WebId, Type: r.Type, ElementName: r.ElementName, DatabaseName: r.DatabaseName }));
331
+ if (type === 'databases' || type === 'all') results.databases = cache.databases.filter(match).map(r => ({ Name: r.Name, WebId: r.WebId, ServerName: r.ServerName }));
332
+ if (type === 'eventframes' || type === 'all') results.eventFrames = cache.eventFrames.filter(match).map(r => ({ Name: r.Name, WebId: r.WebId, StartTime: r.StartTime, EndTime: r.EndTime, TemplateName: r.TemplateName, DatabaseName: r.DatabaseName }));
333
+ return respondCached(results);
334
+ }
335
+ );
336
+
337
+ server.tool(
338
+ 'piwebapi_list_asset_servers',
339
+ 'List all AF servers. Returns from cache if available, otherwise queries PI Web API.',
340
+ {
341
+ selectedFields: z.string().optional().describe('Semicolon-separated list of fields to return'),
342
+ },
343
+ async ({ selectedFields }) => {
344
+ if (sql.hasRows('asset_servers')) return respondCached(sql.assetServers.all());
345
+ const data = await piGet('/assetservers', { selectedFields });
346
+ return respond(data);
347
+ }
348
+ );
349
+
350
+ server.tool(
351
+ 'piwebapi_list_data_servers',
352
+ 'List all PI Data Archive servers. Returns from cache if available, otherwise queries PI Web API.',
353
+ {
354
+ selectedFields: z.string().optional().describe('Semicolon-separated list of fields to return'),
355
+ },
356
+ async ({ selectedFields }) => {
357
+ if (sql.hasRows('data_servers')) return respondCached(sql.dataServers.all());
358
+ const data = await piGet('/dataservers', { selectedFields });
359
+ return respond(data);
360
+ }
361
+ );
362
+
363
+ server.tool(
364
+ 'piwebapi_list_databases',
365
+ 'List AF databases on a server. Returns from cache if available, otherwise queries PI Web API.',
366
+ {
367
+ serverWebId: z.string().describe('WebId of the AF server'),
368
+ selectedFields: z.string().optional().describe('Semicolon-separated list of fields to return'),
369
+ },
370
+ async ({ serverWebId, selectedFields }) => {
371
+ if (sql.hasRows('databases')) {
372
+ const server = db.prepare(`SELECT Name FROM asset_servers WHERE WebId = ?`).get(serverWebId);
373
+ if (server) return respondCached(sql.dbsByServer.all(server.Name));
374
+ }
375
+ const data = await piGet(`/assetservers/${encodeURIComponent(serverWebId)}/assetdatabases`, { selectedFields });
376
+ return respond(data);
377
+ }
378
+ );
379
+
380
+ server.tool(
381
+ 'piwebapi_get_element',
382
+ 'Get an AF Element by WebId or path. Returns from cache if available, otherwise queries PI Web API.',
383
+ {
384
+ path: z.string().optional().describe('Full AF path, e.g. "\\\\AFServer\\MyDB\\Equipment\\Pump1"'),
385
+ webId: z.string().optional().describe('WebId of the element'),
386
+ selectedFields: z.string().optional().describe('Semicolon-separated list of fields to return'),
387
+ },
388
+ async ({ path, webId, selectedFields }) => {
389
+ if (!path && !webId) throw new Error('Provide either path or webId');
390
+ if (sql.hasRows('elements')) {
391
+ const row = webId ? sql.elementByWebId.get(webId) : sql.elementByPath.get(path);
392
+ if (row) return respondCached(row);
393
+ }
394
+ const data = webId
395
+ ? await piGet(`/elements/${encodeURIComponent(webId)}`, { selectedFields })
396
+ : await piGet('/elements', { path, selectedFields });
397
+ return respond(data);
398
+ }
399
+ );
400
+
401
+ server.tool(
402
+ 'piwebapi_list_elements',
403
+ 'List elements. Omit parentWebId to list ALL cached elements. With parentWebId, lists children of that database or element. Always served from cache when available.',
404
+ {
405
+ parentWebId: z.string().optional().describe('WebId of parent AF Database or Element. Omit to return all elements from cache.'),
406
+ parentType: z.enum(['database', 'element']).optional().describe('Whether the parent is a "database" or "element" (required when parentWebId is provided)'),
407
+ nameFilter: z.string().optional().describe('Name filter with wildcards, e.g. "Pump*"'),
408
+ templateName: z.string().optional().describe('Only return elements based on this template'),
409
+ maxCount: z.number().int().optional().describe('Maximum number of results (default 1000)'),
410
+ startIndex: z.number().int().optional().describe('Pagination start index'),
411
+ searchFullHierarchy: z.boolean().optional().describe('Search all descendants, not just immediate children'),
412
+ selectedFields: z.string().optional().describe('Semicolon-separated list of fields to return'),
413
+ },
414
+ async ({ parentWebId, parentType, nameFilter, templateName, maxCount, startIndex, searchFullHierarchy, selectedFields }) => {
415
+ if (sql.hasRows('elements')) {
416
+ let rows;
417
+ if (!parentWebId) {
418
+ // No filter — return entire cache
419
+ rows = db.prepare(`SELECT * FROM elements`).all();
420
+ } else if (parentType === 'database') {
421
+ const dbRow = sql.dbByWebId.get(parentWebId);
422
+ if (dbRow) {
423
+ rows = sql.elementsByDb.all(dbRow.Name);
424
+ if (!searchFullHierarchy) {
425
+ const depth = dbRow.Path.split('\\').filter(Boolean).length + 1;
426
+ rows = rows.filter(r => r.Path.split('\\').filter(Boolean).length === depth);
427
+ }
428
+ }
429
+ } else {
430
+ const parent = sql.elementByWebId.get(parentWebId);
431
+ if (parent) {
432
+ const prefix = parent.Path + '\\';
433
+ rows = searchFullHierarchy
434
+ ? db.prepare(`SELECT * FROM elements WHERE Path LIKE ?`).all(prefix + '%')
435
+ : db.prepare(`SELECT * FROM elements WHERE Path LIKE ? AND Path NOT LIKE ?`).all(prefix + '%', prefix + '%\\%');
436
+ }
437
+ }
438
+ if (rows) {
439
+ if (nameFilter) { const pat = nameFilter.replace(/\*/g, '').toLowerCase(); rows = rows.filter(r => r.Name.toLowerCase().includes(pat)); }
440
+ if (templateName) rows = rows.filter(r => r.TemplateName === templateName);
441
+ const start = startIndex ?? 0;
442
+ return respondCached(rows.slice(start, start + (maxCount ?? 1000)));
443
+ }
444
+ }
445
+ if (!parentWebId) throw new Error('Cache is empty — provide parentWebId to query PI Web API directly');
446
+ const base = parentType === 'database'
447
+ ? `/assetdatabases/${encodeURIComponent(parentWebId)}/elements`
448
+ : `/elements/${encodeURIComponent(parentWebId)}/elements`;
449
+ const data = await piGet(base, { nameFilter, templateName, maxCount, startIndex, searchFullHierarchy, selectedFields });
450
+ return respond(data);
451
+ }
452
+ );
453
+
454
+ server.tool(
455
+ 'piwebapi_search_elements',
456
+ 'Search AF Elements by name/template. Searches cache first, falls back to PI Web API for advanced query syntax.',
457
+ {
458
+ query: z.string().describe('Search query, e.g. "name:Pump*" or "name:Compressor* template:RotatingEquipment"'),
459
+ databaseWebId: z.string().optional().describe('Restrict search to a specific AF Database by its WebId'),
460
+ maxCount: z.number().int().optional().describe('Maximum results to return (default 1000)'),
461
+ startIndex: z.number().int().optional().describe('Pagination start index'),
462
+ selectedFields: z.string().optional().describe('Semicolon-separated list of fields to return'),
463
+ },
464
+ async ({ query, databaseWebId, maxCount, startIndex, selectedFields }) => {
465
+ if (sql.hasRows('elements')) {
466
+ const nameMatch = query.match(/name:(\S+)/i);
467
+ const templateMatch = query.match(/template:(\S+)/i);
468
+ if (nameMatch || templateMatch) {
469
+ let rows = databaseWebId
470
+ ? (() => { const d = sql.dbByWebId.get(databaseWebId); return d ? sql.elementsByDb.all(d.Name) : []; })()
471
+ : db.prepare(`SELECT * FROM elements`).all();
472
+ if (nameMatch) {
473
+ const pat = nameMatch[1].replace(/\*/g, '').toLowerCase();
474
+ rows = rows.filter(r => r.Name.toLowerCase().includes(pat));
475
+ }
476
+ if (templateMatch) {
477
+ const pat = templateMatch[1].replace(/\*/g, '').toLowerCase();
478
+ rows = rows.filter(r => r.TemplateName?.toLowerCase().includes(pat));
479
+ }
480
+ const start = startIndex ?? 0;
481
+ return respondCached(rows.slice(start, start + (maxCount ?? 1000)));
482
+ }
483
+ }
484
+ const data = await piGet('/elements/search', { query, databaseWebId, maxCount, startIndex, selectedFields });
485
+ return respond(data);
486
+ }
487
+ );
488
+
489
+ server.tool(
490
+ 'piwebapi_get_element_attributes',
491
+ 'Get attributes of an AF Element. Returns from cache if available, otherwise queries PI Web API.',
492
+ {
493
+ elementWebId: z.string().describe('WebId of the AF Element'),
494
+ nameFilter: z.string().optional().describe('Filter by attribute name with wildcards'),
495
+ categoryName: z.string().optional().describe('Filter by attribute category'),
496
+ templateName: z.string().optional().describe('Filter by template name'),
497
+ valueType: z.string().optional().describe('Filter by value type, e.g. "Double", "String"'),
498
+ searchFullHierarchy: z.boolean().optional().describe('Include attributes from child elements'),
499
+ maxCount: z.number().int().optional().describe('Maximum results (default 1000)'),
500
+ selectedFields: z.string().optional().describe('Semicolon-separated list of fields to return'),
501
+ },
502
+ async ({ elementWebId, nameFilter, categoryName, templateName, valueType, searchFullHierarchy, maxCount, selectedFields }) => {
503
+ if (sql.hasRows('attributes')) {
504
+ let rows = sql.attrsByElement.all(elementWebId);
505
+ if (nameFilter) { const pat = nameFilter.replace(/\*/g, '').toLowerCase(); rows = rows.filter(r => r.Name.toLowerCase().includes(pat)); }
506
+ if (valueType) rows = rows.filter(r => r.Type === valueType);
507
+ return respondCached(rows.slice(0, maxCount ?? 1000));
508
+ }
509
+ const data = await piGet(`/elements/${encodeURIComponent(elementWebId)}/attributes`, {
510
+ nameFilter, categoryName, templateName, valueType, searchFullHierarchy, maxCount, selectedFields,
511
+ });
512
+ return respond(data);
513
+ }
514
+ );
515
+
516
+ server.tool(
517
+ 'piwebapi_get_attribute_value',
518
+ 'Get the current value of an AF Attribute by its WebId.',
519
+ {
520
+ attributeWebId: z.string().describe('WebId of the AF Attribute'),
521
+ },
522
+ async ({ attributeWebId }) => {
523
+ const data = await piGet(`/attributes/${encodeURIComponent(attributeWebId)}/value`);
524
+ return respond(data);
525
+ }
526
+ );
527
+
528
+ server.tool(
529
+ 'piwebapi_get_stream_end',
530
+ 'Get the latest/most recent value for a PI tag or AF Attribute stream by WebId.',
531
+ {
532
+ webId: z.string().describe('WebId of the PI Point or AF Attribute'),
533
+ desiredUnits: z.string().optional().describe('Desired engineering units for the returned value'),
534
+ selectedFields: z.string().optional().describe('Semicolon-separated list of fields to return'),
535
+ },
536
+ async ({ webId, desiredUnits, selectedFields }) => {
537
+ const data = await piGet(`/streams/${encodeURIComponent(webId)}/end`, { desiredUnits, selectedFields });
538
+ return respond(data);
539
+ }
540
+ );
541
+
542
+ server.tool(
543
+ 'piwebapi_get_stream_recorded',
544
+ 'Get recorded (raw) historical values from a PI tag or AF Attribute stream over a time range.',
545
+ {
546
+ webId: z.string().describe('WebId of the PI Point or AF Attribute'),
547
+ startTime: z.string().optional().describe('Start time, e.g. "*-1d", "2024-01-01T00:00:00Z", or PI time expressions like "y" (yesterday)'),
548
+ endTime: z.string().optional().describe('End time, e.g. "*" (now), "2024-01-02T00:00:00Z"'),
549
+ maxCount: z.number().int().optional().describe('Maximum number of values to return (default 1000)'),
550
+ boundaryType: z.enum(['Inside', 'Outside', 'Interpolated', 'ExactOrBefore', 'ExactOrAfter']).optional().describe('Boundary retrieval type'),
551
+ filterExpression: z.string().optional().describe('PI performance equation filter'),
552
+ desiredUnits: z.string().optional().describe('Desired engineering units'),
553
+ selectedFields: z.string().optional().describe('Semicolon-separated list of fields to return'),
554
+ },
555
+ async ({ webId, startTime, endTime, maxCount, boundaryType, filterExpression, desiredUnits, selectedFields }) => {
556
+ const data = await piGet(`/streams/${encodeURIComponent(webId)}/recorded`, {
557
+ startTime, endTime, maxCount, boundaryType, filterExpression, desiredUnits, selectedFields,
558
+ });
559
+ return respond(data);
560
+ }
561
+ );
562
+
563
+ server.tool(
564
+ 'piwebapi_get_stream_interpolated',
565
+ 'Get interpolated values at regular intervals from a PI tag or AF Attribute stream.',
566
+ {
567
+ webId: z.string().describe('WebId of the PI Point or AF Attribute'),
568
+ startTime: z.string().optional().describe('Start time, e.g. "*-1d" or "2024-01-01T00:00:00Z"'),
569
+ endTime: z.string().optional().describe('End time, e.g. "*" (now)'),
570
+ interval: z.string().optional().describe('Time interval between values, e.g. "1h", "15m", "1d"'),
571
+ desiredUnits: z.string().optional().describe('Desired engineering units'),
572
+ filterExpression: z.string().optional().describe('PI performance equation filter'),
573
+ selectedFields: z.string().optional().describe('Semicolon-separated list of fields to return'),
574
+ },
575
+ async ({ webId, startTime, endTime, interval, desiredUnits, filterExpression, selectedFields }) => {
576
+ const data = await piGet(`/streams/${encodeURIComponent(webId)}/interpolated`, {
577
+ startTime, endTime, interval, desiredUnits, filterExpression, selectedFields,
578
+ });
579
+ return respond(data);
580
+ }
581
+ );
582
+
583
+ server.tool(
584
+ 'piwebapi_get_stream_summary',
585
+ 'Get statistical summary (min, max, average, std dev, count, etc.) for a PI tag or AF Attribute over a time range.',
586
+ {
587
+ webId: z.string().describe('WebId of the PI Point or AF Attribute'),
588
+ startTime: z.string().optional().describe('Start time, e.g. "*-1d" or "2024-01-01T00:00:00Z"'),
589
+ endTime: z.string().optional().describe('End time, e.g. "*" (now)'),
590
+ summaryType: z.string().optional().describe('Comma or pipe separated summary types: Average, Maximum, Minimum, StdDev, Total, Count, PercentGood, etc. Default: All'),
591
+ calculationBasis: z.enum(['TimeWeighted', 'EventWeighted', 'TimeWeightedContinuous', 'TimeWeightedDiscrete', 'EventWeightedExcludeMostRecentEvent', 'EventWeightedExcludeEarliestEvent', 'EventWeightedIncludeBothEnds']).optional().describe('Basis for calculation'),
592
+ filterExpression: z.string().optional().describe('PI performance equation filter'),
593
+ selectedFields: z.string().optional().describe('Semicolon-separated list of fields to return'),
594
+ },
595
+ async ({ webId, startTime, endTime, summaryType, calculationBasis, filterExpression, selectedFields }) => {
596
+ const data = await piGet(`/streams/${encodeURIComponent(webId)}/summary`, {
597
+ startTime, endTime, summaryType, calculationBasis, filterExpression, selectedFields,
598
+ });
599
+ return respond(data);
600
+ }
601
+ );
602
+
603
+ server.tool(
604
+ 'piwebapi_get_stream_plot',
605
+ 'Get plot (chart-optimized) values for a PI tag or AF Attribute. Returns min/max pairs per interval, ideal for trending/visualization.',
606
+ {
607
+ webId: z.string().describe('WebId of the PI Point or AF Attribute'),
608
+ startTime: z.string().optional().describe('Start time, e.g. "*-1d"'),
609
+ endTime: z.string().optional().describe('End time, e.g. "*"'),
610
+ intervals: z.number().int().optional().describe('Number of intervals for plot (default 24)'),
611
+ desiredUnits: z.string().optional().describe('Desired engineering units'),
612
+ selectedFields: z.string().optional().describe('Semicolon-separated list of fields to return'),
613
+ },
614
+ async ({ webId, startTime, endTime, intervals, desiredUnits, selectedFields }) => {
615
+ const data = await piGet(`/streams/${encodeURIComponent(webId)}/plot`, {
616
+ startTime, endTime, intervals, desiredUnits, selectedFields,
617
+ });
618
+ return respond(data);
619
+ }
620
+ );
621
+
622
+ server.tool(
623
+ 'piwebapi_get_point',
624
+ 'Get a PI Point by WebId or path. Returns from cache if available, otherwise queries PI Web API.',
625
+ {
626
+ path: z.string().optional().describe('Full path to the PI tag, e.g. "\\\\PISERVER\\CDT158"'),
627
+ webId: z.string().optional().describe('WebId of the PI Point (alternative to path)'),
628
+ selectedFields: z.string().optional().describe('Semicolon-separated list of fields to return'),
629
+ },
630
+ async ({ path, webId, selectedFields }) => {
631
+ if (!path && !webId) throw new Error('Provide either path or webId');
632
+ if (sql.hasRows('points')) {
633
+ const row = webId ? sql.pointByWebId.get(webId) : sql.pointByPath.get(path);
634
+ if (row) return respondCached(row);
635
+ }
636
+ const data = webId
637
+ ? await piGet(`/points/${encodeURIComponent(webId)}`, { selectedFields })
638
+ : await piGet('/points', { path, selectedFields });
639
+ return respond(data);
640
+ }
641
+ );
642
+
643
+ server.tool(
644
+ 'piwebapi_list_data_server_points',
645
+ 'List or search PI Points on a data server. Returns from cache if available, otherwise queries PI Web API.',
646
+ {
647
+ serverWebId: z.string().describe('WebId of the PI Data Archive server'),
648
+ nameFilter: z.string().optional().describe('Tag name filter with wildcards, e.g. "SIN*" or "*Flow*"'),
649
+ maxCount: z.number().int().optional().describe('Maximum number of results (default 1000)'),
650
+ startIndex: z.number().int().optional().describe('Pagination start index'),
651
+ selectedFields: z.string().optional().describe('Semicolon-separated list of fields to return'),
652
+ },
653
+ async ({ serverWebId, nameFilter, maxCount, startIndex, selectedFields }) => {
654
+ if (sql.hasRows('points')) {
655
+ const srv = db.prepare(`SELECT Name FROM data_servers WHERE WebId = ?`).get(serverWebId);
656
+ if (srv) {
657
+ let rows = sql.pointsByServer.all(srv.Name);
658
+ if (nameFilter) { const pat = nameFilter.replace(/\*/g, '').toLowerCase(); rows = rows.filter(r => r.Name.toLowerCase().includes(pat)); }
659
+ const start = startIndex ?? 0;
660
+ return respondCached(rows.slice(start, start + (maxCount ?? 1000)));
661
+ }
662
+ }
663
+ const data = await piGet(`/dataservers/${encodeURIComponent(serverWebId)}/points`, {
664
+ nameFilter, maxCount, startIndex, selectedFields,
665
+ });
666
+ return respond(data);
667
+ }
668
+ );
669
+
670
+ server.tool(
671
+ 'piwebapi_search_points',
672
+ 'Search PI Points (tags) by name across all cached data servers. Searches cache first, falls back to PI Web API.',
673
+ {
674
+ query: z.string().describe('Name or partial name to search, wildcards supported e.g. "Sin*" or "*Flow*"'),
675
+ serverWebId: z.string().optional().describe('Restrict search to a specific PI Data Archive server by WebId'),
676
+ maxCount: z.number().int().optional().describe('Maximum results to return (default 1000)'),
677
+ startIndex: z.number().int().optional().describe('Pagination start index'),
678
+ },
679
+ async ({ query, serverWebId, maxCount, startIndex }) => {
680
+ if (sql.hasRows('points')) {
681
+ let rows = serverWebId
682
+ ? (() => { const s = db.prepare(`SELECT Name FROM data_servers WHERE WebId = ?`).get(serverWebId); return s ? sql.pointsByServer.all(s.Name) : []; })()
683
+ : db.prepare(`SELECT * FROM points`).all();
684
+ const pat = query.replace(/\*/g, '').toLowerCase();
685
+ rows = rows.filter(r => r.Name.toLowerCase().includes(pat));
686
+ const start = startIndex ?? 0;
687
+ return respondCached(rows.slice(start, start + (maxCount ?? 1000)));
688
+ }
689
+ // Fallback: search via each data server
690
+ const servers = serverWebId
691
+ ? [{ WebId: serverWebId }]
692
+ : (await piGet('/dataservers'))?.Items ?? [];
693
+ const results = await Promise.all(
694
+ servers.map(s =>
695
+ piGet(`/dataservers/${encodeURIComponent(s.WebId)}/points`, { nameFilter: query, maxCount, startIndex })
696
+ .then(r => r?.Items ?? [])
697
+ .catch(() => [])
698
+ )
699
+ );
700
+ return respond(results.flat());
701
+ }
702
+ );
703
+
704
+ server.tool(
705
+ 'piwebapi_search_event_frames',
706
+ 'Search for Event Frames in AF using a query string.',
707
+ {
708
+ query: z.string().describe('Search query, e.g. "name:Alarm* template:HighTemp"'),
709
+ databaseWebId: z.string().optional().describe('Restrict search to a specific AF Database by its WebId'),
710
+ maxCount: z.number().int().optional().describe('Maximum results to return'),
711
+ startIndex: z.number().int().optional().describe('Pagination start index'),
712
+ selectedFields: z.string().optional().describe('Semicolon-separated list of fields to return'),
713
+ },
714
+ async ({ query, databaseWebId, maxCount, startIndex, selectedFields }) => {
715
+ const data = await piGet('/eventframes/search', { query, databaseWebId, maxCount, startIndex, selectedFields });
716
+ return respond(data);
717
+ }
718
+ );
719
+
720
+ server.tool(
721
+ 'piwebapi_get_event_frame',
722
+ 'Get details of a specific Event Frame by its WebId.',
723
+ {
724
+ webId: z.string().describe('WebId of the Event Frame'),
725
+ selectedFields: z.string().optional().describe('Semicolon-separated list of fields to return'),
726
+ },
727
+ async ({ webId, selectedFields }) => {
728
+ const data = await piGet(`/eventframes/${encodeURIComponent(webId)}`, { selectedFields });
729
+ return respond(data);
730
+ }
731
+ );
732
+
733
+ server.tool(
734
+ 'piwebapi_system_status',
735
+ 'Get the current status and health of the PI Web API server.',
736
+ {},
737
+ async () => {
738
+ const data = await piGet('/system/status');
739
+ return respond(data);
740
+ }
741
+ );
742
+
743
+ server.tool(
744
+ 'piwebapi_system_versions',
745
+ 'Get version information for all PI components connected to this PI Web API instance.',
746
+ {},
747
+ async () => {
748
+ const data = await piGet('/system/versions');
749
+ return respond(data);
750
+ }
751
+ );
752
+
753
+ server.tool(
754
+ 'piwebapi_batch',
755
+ 'Execute multiple PI Web API requests in a single HTTP call for efficiency. Each request can reference results of previous requests using "{N.Content.Field}" syntax.',
756
+ {
757
+ requests: z.record(z.object({
758
+ Method: z.string().describe('HTTP method: GET, POST, PUT, DELETE'),
759
+ Resource: z.string().describe('Relative URL, e.g. "/piwebapi/streams/{webId}/end"'),
760
+ Headers: z.record(z.string()).optional().describe('Optional HTTP headers'),
761
+ Content: z.any().optional().describe('Request body for POST/PUT'),
762
+ Parameters: z.array(z.string()).optional().describe('References to prior request results'),
763
+ ParentIds: z.array(z.string()).optional().describe('IDs of requests that must complete first'),
764
+ })).describe('Object where keys are request IDs and values are request objects'),
765
+ },
766
+ async ({ requests }) => {
767
+ const data = await piPost('/batch', requests);
768
+ return respond(data);
769
+ }
770
+ );
771
+
772
+ server.tool(
773
+ 'piwebapi_get_database',
774
+ 'Get an AF Asset Database by WebId or path. Returns from cache if available, otherwise queries PI Web API.',
775
+ {
776
+ webId: z.string().optional().describe('WebId of the database'),
777
+ path: z.string().optional().describe('Full path, e.g. "\\\\AFServer\\MyDB"'),
778
+ selectedFields: z.string().optional().describe('Semicolon-separated fields to return'),
779
+ },
780
+ async ({ webId, path, selectedFields }) => {
781
+ if (!webId && !path) throw new Error('Provide either webId or path');
782
+ if (sql.hasRows('databases')) {
783
+ const row = webId ? sql.dbByWebId.get(webId) : sql.dbByPath.get(path);
784
+ if (row) return respondCached(row);
785
+ }
786
+ const data = webId
787
+ ? await piGet(`/assetdatabases/${encodeURIComponent(webId)}`, { selectedFields })
788
+ : await piGet('/assetdatabases', { path, selectedFields });
789
+ return respond(data);
790
+ }
791
+ );
792
+
793
+ server.tool(
794
+ 'piwebapi_get_attribute',
795
+ 'Get an AF Attribute by WebId. Returns from cache if available, otherwise queries PI Web API.',
796
+ {
797
+ webId: z.string().describe('WebId of the AF Attribute'),
798
+ selectedFields: z.string().optional().describe('Semicolon-separated fields to return'),
799
+ },
800
+ async ({ webId, selectedFields }) => {
801
+ if (sql.hasRows('attributes')) {
802
+ const row = sql.attrByWebId.get(webId);
803
+ if (row) return respondCached(row);
804
+ }
805
+ const data = await piGet(`/attributes/${encodeURIComponent(webId)}`, { selectedFields });
806
+ return respond(data);
807
+ }
808
+ );
809
+
810
+ server.tool(
811
+ 'piwebapi_list_element_templates',
812
+ 'List Element Templates defined in an AF Database.',
813
+ {
814
+ databaseWebId: z.string().describe('WebId of the AF Database'),
815
+ nameFilter: z.string().optional().describe('Filter by template name with wildcards'),
816
+ maxCount: z.number().int().optional().describe('Maximum results (default 1000)'),
817
+ selectedFields: z.string().optional().describe('Semicolon-separated fields to return'),
818
+ },
819
+ async ({ databaseWebId, nameFilter, maxCount, selectedFields }) => {
820
+ const data = await piGet(`/assetdatabases/${encodeURIComponent(databaseWebId)}/elementtemplates`, {
821
+ nameFilter, maxCount, selectedFields,
822
+ });
823
+ return respond(data);
824
+ }
825
+ );
826
+
827
+ server.tool(
828
+ 'piwebapi_list_element_event_frames',
829
+ 'List Event Frames associated with a specific AF Element.',
830
+ {
831
+ elementWebId: z.string().describe('WebId of the AF Element'),
832
+ startTime: z.string().optional().describe('Start time filter, e.g. "*-7d"'),
833
+ endTime: z.string().optional().describe('End time filter, e.g. "*"'),
834
+ nameFilter: z.string().optional().describe('Event frame name filter with wildcards'),
835
+ templateName: z.string().optional().describe('Filter by event frame template name'),
836
+ maxCount: z.number().int().optional().describe('Maximum results'),
837
+ selectedFields: z.string().optional().describe('Semicolon-separated fields to return'),
838
+ },
839
+ async ({ elementWebId, startTime, endTime, nameFilter, templateName, maxCount, selectedFields }) => {
840
+ const data = await piGet(`/elements/${encodeURIComponent(elementWebId)}/eventframes`, {
841
+ startTime, endTime, nameFilter, templateName, maxCount, selectedFields,
842
+ });
843
+ return respond(data);
844
+ }
845
+ );
846
+
847
+ server.tool(
848
+ 'piwebapi_get_event_frame_attributes',
849
+ 'Get attributes of an Event Frame by the event frame\'s WebId.',
850
+ {
851
+ eventFrameWebId: z.string().describe('WebId of the Event Frame'),
852
+ nameFilter: z.string().optional().describe('Filter by attribute name with wildcards'),
853
+ maxCount: z.number().int().optional().describe('Maximum results'),
854
+ selectedFields: z.string().optional().describe('Semicolon-separated fields to return'),
855
+ },
856
+ async ({ eventFrameWebId, nameFilter, maxCount, selectedFields }) => {
857
+ const data = await piGet(`/eventframes/${encodeURIComponent(eventFrameWebId)}/attributes`, {
858
+ nameFilter, maxCount, selectedFields,
859
+ });
860
+ return respond(data);
861
+ }
862
+ );
863
+
864
+ server.tool(
865
+ 'piwebapi_streamsets_recorded',
866
+ 'Get recorded (raw) historical values for multiple PI tags or AF Attributes in a single request.',
867
+ {
868
+ webId: z.array(z.string()).describe('Array of WebIds'),
869
+ startTime: z.string().optional().describe('Start time, e.g. "*-1d"'),
870
+ endTime: z.string().optional().describe('End time, e.g. "*"'),
871
+ maxCount: z.number().int().optional().describe('Maximum values per stream'),
872
+ selectedFields: z.string().optional().describe('Semicolon-separated fields to return'),
873
+ },
874
+ async ({ webId, startTime, endTime, maxCount, selectedFields }) => {
875
+ const params = new URLSearchParams();
876
+ webId.forEach(id => params.append('webId', id));
877
+ if (startTime) params.append('startTime', startTime);
878
+ if (endTime) params.append('endTime', endTime);
879
+ if (maxCount) params.append('maxCount', maxCount);
880
+ if (selectedFields) params.append('selectedFields', selectedFields);
881
+ const res = await piClient.get('/streamsets/recorded', { params });
882
+ return respond(res.data);
883
+ }
884
+ );
885
+
886
+ server.tool(
887
+ 'piwebapi_streamsets_interpolated',
888
+ 'Get interpolated values at regular intervals for multiple PI tags or AF Attributes.',
889
+ {
890
+ webId: z.array(z.string()).describe('Array of WebIds'),
891
+ startTime: z.string().optional().describe('Start time, e.g. "*-1d"'),
892
+ endTime: z.string().optional().describe('End time, e.g. "*"'),
893
+ interval: z.string().optional().describe('Interval between values, e.g. "1h", "15m"'),
894
+ selectedFields: z.string().optional().describe('Semicolon-separated fields to return'),
895
+ },
896
+ async ({ webId, startTime, endTime, interval, selectedFields }) => {
897
+ const params = new URLSearchParams();
898
+ webId.forEach(id => params.append('webId', id));
899
+ if (startTime) params.append('startTime', startTime);
900
+ if (endTime) params.append('endTime', endTime);
901
+ if (interval) params.append('interval', interval);
902
+ if (selectedFields) params.append('selectedFields', selectedFields);
903
+ const res = await piClient.get('/streamsets/interpolated', { params });
904
+ return respond(res.data);
905
+ }
906
+ );
907
+
908
+ server.tool(
909
+ 'piwebapi_streamsets_summary',
910
+ 'Get statistical summaries for multiple PI tags or AF Attributes in a single request.',
911
+ {
912
+ webId: z.array(z.string()).describe('Array of WebIds'),
913
+ startTime: z.string().optional().describe('Start time, e.g. "*-1d"'),
914
+ endTime: z.string().optional().describe('End time, e.g. "*"'),
915
+ summaryType: z.string().optional().describe('Summary types: Average, Maximum, Minimum, StdDev, Total, Count, etc.'),
916
+ calculationBasis: z.enum(['TimeWeighted', 'EventWeighted']).optional().describe('Calculation basis'),
917
+ selectedFields: z.string().optional().describe('Semicolon-separated fields to return'),
918
+ },
919
+ async ({ webId, startTime, endTime, summaryType, calculationBasis, selectedFields }) => {
920
+ const params = new URLSearchParams();
921
+ webId.forEach(id => params.append('webId', id));
922
+ if (startTime) params.append('startTime', startTime);
923
+ if (endTime) params.append('endTime', endTime);
924
+ if (summaryType) params.append('summaryType', summaryType);
925
+ if (calculationBasis) params.append('calculationBasis', calculationBasis);
926
+ if (selectedFields) params.append('selectedFields', selectedFields);
927
+ const res = await piClient.get('/streamsets/summary', { params });
928
+ return respond(res.data);
929
+ }
930
+ );
931
+
932
+ server.tool(
933
+ 'piwebapi_streamsets_end',
934
+ 'Get the latest values for multiple PI tags or AF Attributes in a single request.',
935
+ {
936
+ webId: z.array(z.string()).describe('Array of WebIds for PI Points or AF Attributes'),
937
+ selectedFields: z.string().optional().describe('Semicolon-separated list of fields to return'),
938
+ },
939
+ async ({ webId, selectedFields }) => {
940
+ const params = new URLSearchParams();
941
+ webId.forEach(id => params.append('webId', id));
942
+ if (selectedFields) params.append('selectedFields', selectedFields);
943
+ const res = await piClient.get('/streamsets/end', { params });
944
+ return respond(res.data);
945
+ }
946
+ );
947
+
948
+ return server;
949
+ }
950
+
951
+ module.exports = { createMcpServer };