@objectstack/mcp 8.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.
- package/LICENSE +93 -0
- package/README.md +528 -0
- package/dist/index.cjs +817 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +284 -0
- package/dist/index.d.ts +284 -0
- package/dist/index.js +785 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
MCPServerPlugin: () => MCPServerPlugin,
|
|
24
|
+
MCPServerRuntime: () => MCPServerRuntime,
|
|
25
|
+
OBJECTSTACK_SKILL_DESCRIPTION: () => OBJECTSTACK_SKILL_DESCRIPTION,
|
|
26
|
+
OBJECTSTACK_SKILL_NAME: () => OBJECTSTACK_SKILL_NAME,
|
|
27
|
+
registerObjectTools: () => registerObjectTools,
|
|
28
|
+
renderSkillMarkdown: () => renderSkillMarkdown
|
|
29
|
+
});
|
|
30
|
+
module.exports = __toCommonJS(index_exports);
|
|
31
|
+
|
|
32
|
+
// src/plugin.ts
|
|
33
|
+
var import_types = require("@objectstack/types");
|
|
34
|
+
|
|
35
|
+
// src/mcp-server-runtime.ts
|
|
36
|
+
var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
37
|
+
var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
38
|
+
var import_webStandardStreamableHttp = require("@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js");
|
|
39
|
+
|
|
40
|
+
// src/mcp-http-tools.ts
|
|
41
|
+
var import_zod = require("zod");
|
|
42
|
+
var DEFAULT_MAX_LIMIT = 50;
|
|
43
|
+
function isSystemObject(name) {
|
|
44
|
+
return /^sys_/i.test(name);
|
|
45
|
+
}
|
|
46
|
+
function textResult(value) {
|
|
47
|
+
return { content: [{ type: "text", text: jsonText(value) }] };
|
|
48
|
+
}
|
|
49
|
+
function errorResult(message) {
|
|
50
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
51
|
+
}
|
|
52
|
+
function jsonText(value) {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.stringify(value, null, 2);
|
|
55
|
+
} catch {
|
|
56
|
+
return String(value);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function registerObjectTools(server, bridge, options = {}) {
|
|
60
|
+
const allowSystem = options.allowSystemObjects === true;
|
|
61
|
+
const maxLimit = options.maxQueryLimit ?? DEFAULT_MAX_LIMIT;
|
|
62
|
+
const guard = (objectName) => {
|
|
63
|
+
if (!objectName || typeof objectName !== "string") return "objectName is required";
|
|
64
|
+
if (!allowSystem && isSystemObject(objectName)) {
|
|
65
|
+
return `Object "${objectName}" is a system object and is not exposed via MCP`;
|
|
66
|
+
}
|
|
67
|
+
return void 0;
|
|
68
|
+
};
|
|
69
|
+
server.registerTool(
|
|
70
|
+
"list_objects",
|
|
71
|
+
{
|
|
72
|
+
description: "List the data objects (tables) available in this app. Returns each object's name, label and field count.",
|
|
73
|
+
inputSchema: {},
|
|
74
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
|
|
75
|
+
},
|
|
76
|
+
async () => {
|
|
77
|
+
try {
|
|
78
|
+
const objects = await bridge.listObjects();
|
|
79
|
+
const visible = allowSystem ? objects : objects.filter((o) => !isSystemObject(o.name));
|
|
80
|
+
return textResult({ objects: visible, totalCount: visible.length });
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return errorResult(messageOf(err));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
);
|
|
86
|
+
server.registerTool(
|
|
87
|
+
"describe_object",
|
|
88
|
+
{
|
|
89
|
+
description: "Get the schema of a data object: its fields (name, type, label, required) and enabled features.",
|
|
90
|
+
inputSchema: { objectName: import_zod.z.string().describe('The object/table name, e.g. "task"') },
|
|
91
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
|
|
92
|
+
},
|
|
93
|
+
async ({ objectName }) => {
|
|
94
|
+
const bad = guard(objectName);
|
|
95
|
+
if (bad) return errorResult(bad);
|
|
96
|
+
try {
|
|
97
|
+
const def = await bridge.describeObject(objectName);
|
|
98
|
+
if (!def) return errorResult(`Object "${objectName}" not found`);
|
|
99
|
+
return textResult(def);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
return errorResult(messageOf(err));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
);
|
|
105
|
+
server.registerTool(
|
|
106
|
+
"query_records",
|
|
107
|
+
{
|
|
108
|
+
description: "Query records from an object with optional filter, field selection, sorting and pagination. Runs under the caller's permissions and row-level security.",
|
|
109
|
+
inputSchema: {
|
|
110
|
+
objectName: import_zod.z.string().describe("The object/table name"),
|
|
111
|
+
where: import_zod.z.record(import_zod.z.string(), import_zod.z.unknown()).optional().describe('Filter conditions, e.g. {"status":"open"}'),
|
|
112
|
+
fields: import_zod.z.array(import_zod.z.string()).optional().describe("Field names to return (defaults to all)"),
|
|
113
|
+
limit: import_zod.z.number().int().positive().max(maxLimit).optional().describe(`Max rows (\u2264 ${maxLimit})`),
|
|
114
|
+
offset: import_zod.z.number().int().nonnegative().optional().describe("Rows to skip"),
|
|
115
|
+
orderBy: import_zod.z.array(import_zod.z.object({ field: import_zod.z.string(), order: import_zod.z.enum(["asc", "desc"]) })).optional().describe("Sort order")
|
|
116
|
+
},
|
|
117
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
|
|
118
|
+
},
|
|
119
|
+
async ({ objectName, where, fields, limit, offset, orderBy }) => {
|
|
120
|
+
const bad = guard(objectName);
|
|
121
|
+
if (bad) return errorResult(bad);
|
|
122
|
+
try {
|
|
123
|
+
const result = await bridge.query(objectName, {
|
|
124
|
+
where,
|
|
125
|
+
fields,
|
|
126
|
+
limit: Math.min(limit ?? maxLimit, maxLimit),
|
|
127
|
+
offset,
|
|
128
|
+
orderBy
|
|
129
|
+
});
|
|
130
|
+
return textResult(result);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
return errorResult(messageOf(err));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
);
|
|
136
|
+
server.registerTool(
|
|
137
|
+
"get_record",
|
|
138
|
+
{
|
|
139
|
+
description: "Fetch a single record by id.",
|
|
140
|
+
inputSchema: {
|
|
141
|
+
objectName: import_zod.z.string().describe("The object/table name"),
|
|
142
|
+
recordId: import_zod.z.string().describe("The record id")
|
|
143
|
+
},
|
|
144
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
|
|
145
|
+
},
|
|
146
|
+
async ({ objectName, recordId }) => {
|
|
147
|
+
const bad = guard(objectName);
|
|
148
|
+
if (bad) return errorResult(bad);
|
|
149
|
+
try {
|
|
150
|
+
const record = await bridge.get(objectName, recordId);
|
|
151
|
+
if (record == null) return errorResult(`Record "${recordId}" not found in "${objectName}"`);
|
|
152
|
+
return textResult(record);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
return errorResult(messageOf(err));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
);
|
|
158
|
+
server.registerTool(
|
|
159
|
+
"create_record",
|
|
160
|
+
{
|
|
161
|
+
description: "Create a new record. Runs under the caller's permissions and validations.",
|
|
162
|
+
inputSchema: {
|
|
163
|
+
objectName: import_zod.z.string().describe("The object/table name"),
|
|
164
|
+
data: import_zod.z.record(import_zod.z.string(), import_zod.z.unknown()).describe("Field values for the new record")
|
|
165
|
+
},
|
|
166
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
|
|
167
|
+
},
|
|
168
|
+
async ({ objectName, data }) => {
|
|
169
|
+
const bad = guard(objectName);
|
|
170
|
+
if (bad) return errorResult(bad);
|
|
171
|
+
try {
|
|
172
|
+
return textResult(await bridge.create(objectName, data));
|
|
173
|
+
} catch (err) {
|
|
174
|
+
return errorResult(messageOf(err));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
);
|
|
178
|
+
server.registerTool(
|
|
179
|
+
"update_record",
|
|
180
|
+
{
|
|
181
|
+
description: "Update fields on an existing record by id.",
|
|
182
|
+
inputSchema: {
|
|
183
|
+
objectName: import_zod.z.string().describe("The object/table name"),
|
|
184
|
+
recordId: import_zod.z.string().describe("The record id"),
|
|
185
|
+
data: import_zod.z.record(import_zod.z.string(), import_zod.z.unknown()).describe("Field values to change")
|
|
186
|
+
},
|
|
187
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
|
|
188
|
+
},
|
|
189
|
+
async ({ objectName, recordId, data }) => {
|
|
190
|
+
const bad = guard(objectName);
|
|
191
|
+
if (bad) return errorResult(bad);
|
|
192
|
+
try {
|
|
193
|
+
return textResult(await bridge.update(objectName, recordId, data));
|
|
194
|
+
} catch (err) {
|
|
195
|
+
return errorResult(messageOf(err));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
);
|
|
199
|
+
server.registerTool(
|
|
200
|
+
"delete_record",
|
|
201
|
+
{
|
|
202
|
+
description: "Delete a record by id. This is destructive.",
|
|
203
|
+
inputSchema: {
|
|
204
|
+
objectName: import_zod.z.string().describe("The object/table name"),
|
|
205
|
+
recordId: import_zod.z.string().describe("The record id")
|
|
206
|
+
},
|
|
207
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false }
|
|
208
|
+
},
|
|
209
|
+
async ({ objectName, recordId }) => {
|
|
210
|
+
const bad = guard(objectName);
|
|
211
|
+
if (bad) return errorResult(bad);
|
|
212
|
+
try {
|
|
213
|
+
return textResult(await bridge.remove(objectName, recordId));
|
|
214
|
+
} catch (err) {
|
|
215
|
+
return errorResult(messageOf(err));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
function messageOf(err) {
|
|
221
|
+
if (err instanceof Error) return err.message;
|
|
222
|
+
if (err && typeof err === "object" && "message" in err) return String(err.message);
|
|
223
|
+
return String(err);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// src/mcp-server-runtime.ts
|
|
227
|
+
var import_zod2 = require("zod");
|
|
228
|
+
var READ_ONLY_TOOLS = /* @__PURE__ */ new Set([
|
|
229
|
+
"list_objects",
|
|
230
|
+
"describe_object",
|
|
231
|
+
"query_records",
|
|
232
|
+
"get_record",
|
|
233
|
+
"aggregate_data"
|
|
234
|
+
]);
|
|
235
|
+
var DESTRUCTIVE_TOOLS = /* @__PURE__ */ new Set([
|
|
236
|
+
"delete_field"
|
|
237
|
+
]);
|
|
238
|
+
var MCPServerRuntime = class _MCPServerRuntime {
|
|
239
|
+
constructor(config = {}) {
|
|
240
|
+
this.started = false;
|
|
241
|
+
this.config = {
|
|
242
|
+
name: "objectstack",
|
|
243
|
+
version: "1.0.0",
|
|
244
|
+
transport: "stdio",
|
|
245
|
+
...config
|
|
246
|
+
};
|
|
247
|
+
this.mcpServer = new import_mcp.McpServer(
|
|
248
|
+
{
|
|
249
|
+
name: this.config.name,
|
|
250
|
+
version: this.config.version
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
capabilities: {
|
|
254
|
+
resources: {},
|
|
255
|
+
tools: {},
|
|
256
|
+
prompts: {},
|
|
257
|
+
logging: {}
|
|
258
|
+
},
|
|
259
|
+
instructions: this.config.instructions ?? "ObjectStack MCP Server \u2014 access data objects, AI tools, and agent prompts."
|
|
260
|
+
}
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
/** The underlying McpServer instance (for advanced use cases). */
|
|
264
|
+
get server() {
|
|
265
|
+
return this.mcpServer;
|
|
266
|
+
}
|
|
267
|
+
/** Whether the server is currently connected and running. */
|
|
268
|
+
get isStarted() {
|
|
269
|
+
return this.started;
|
|
270
|
+
}
|
|
271
|
+
// ── Helpers ─────────────────────────────────────────────────────
|
|
272
|
+
/**
|
|
273
|
+
* Extract the text value from a ToolExecutionResult's output.
|
|
274
|
+
*
|
|
275
|
+
* The output may be a `{ type: 'text', value: string }` object (from the
|
|
276
|
+
* Vercel AI SDK ToolResultPart) or any serialisable value.
|
|
277
|
+
*/
|
|
278
|
+
static formatToolOutput(result) {
|
|
279
|
+
const output = result.output;
|
|
280
|
+
if (output && typeof output === "object" && "value" in output) {
|
|
281
|
+
return String(output.value);
|
|
282
|
+
}
|
|
283
|
+
return JSON.stringify(output ?? "");
|
|
284
|
+
}
|
|
285
|
+
// ── Tool Bridge ────────────────────────────────────────────────
|
|
286
|
+
/**
|
|
287
|
+
* Bridge all tools from the ToolRegistry to MCP tools.
|
|
288
|
+
*
|
|
289
|
+
* Each registered tool becomes an MCP tool with the same name, description,
|
|
290
|
+
* and JSON Schema parameters. The handler delegates to the ToolRegistry's
|
|
291
|
+
* execute path.
|
|
292
|
+
*/
|
|
293
|
+
bridgeTools(toolRegistry) {
|
|
294
|
+
const tools = toolRegistry.getAll();
|
|
295
|
+
const logger = this.config.logger;
|
|
296
|
+
for (const tool of tools) {
|
|
297
|
+
this.registerToolFromDefinition(tool, toolRegistry);
|
|
298
|
+
}
|
|
299
|
+
logger?.info(`[MCP] Bridged ${tools.length} tools from ToolRegistry`);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Register a single tool on the MCP server from an AIToolDefinition.
|
|
303
|
+
*/
|
|
304
|
+
registerToolFromDefinition(tool, toolRegistry) {
|
|
305
|
+
const logger = this.config.logger;
|
|
306
|
+
this.mcpServer.registerTool(
|
|
307
|
+
tool.name,
|
|
308
|
+
{
|
|
309
|
+
description: tool.description,
|
|
310
|
+
annotations: {
|
|
311
|
+
// Mark tools with write side-effects for destructive operations
|
|
312
|
+
destructiveHint: this.isDestructiveTool(tool.name),
|
|
313
|
+
readOnlyHint: this.isReadOnlyTool(tool.name),
|
|
314
|
+
openWorldHint: false
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
async (extra) => {
|
|
318
|
+
const rawExtra = extra;
|
|
319
|
+
const args = rawExtra.arguments ?? {};
|
|
320
|
+
try {
|
|
321
|
+
const result = await toolRegistry.execute({
|
|
322
|
+
type: "tool-call",
|
|
323
|
+
toolCallId: `mcp-${tool.name}-${Date.now()}`,
|
|
324
|
+
toolName: tool.name,
|
|
325
|
+
input: args
|
|
326
|
+
});
|
|
327
|
+
const outputText = _MCPServerRuntime.formatToolOutput(result);
|
|
328
|
+
if (result.isError) {
|
|
329
|
+
return {
|
|
330
|
+
content: [{ type: "text", text: outputText }],
|
|
331
|
+
isError: true
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
content: [{ type: "text", text: outputText }]
|
|
336
|
+
};
|
|
337
|
+
} catch (err) {
|
|
338
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
339
|
+
logger?.warn(`[MCP] Tool "${tool.name}" execution failed:`, { error: message });
|
|
340
|
+
return {
|
|
341
|
+
content: [{ type: "text", text: message }],
|
|
342
|
+
isError: true
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Check if a tool is read-only (data query tools).
|
|
350
|
+
*/
|
|
351
|
+
isReadOnlyTool(name) {
|
|
352
|
+
return READ_ONLY_TOOLS.has(name);
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Check if a tool performs destructive operations.
|
|
356
|
+
*/
|
|
357
|
+
isDestructiveTool(name) {
|
|
358
|
+
return DESTRUCTIVE_TOOLS.has(name);
|
|
359
|
+
}
|
|
360
|
+
// ── Resource Bridge ────────────────────────────────────────────
|
|
361
|
+
/**
|
|
362
|
+
* Bridge metadata service and data engine to MCP resources.
|
|
363
|
+
*
|
|
364
|
+
* Exposes:
|
|
365
|
+
* - `objectstack://objects` — List all data objects
|
|
366
|
+
* - `objectstack://objects/{objectName}` — Get object schema
|
|
367
|
+
* - `objectstack://objects/{objectName}/records/{recordId}` — Get a specific record
|
|
368
|
+
* - `objectstack://metadata/types` — List all metadata types
|
|
369
|
+
*/
|
|
370
|
+
bridgeResources(metadataService, dataEngine) {
|
|
371
|
+
const logger = this.config.logger;
|
|
372
|
+
let resourceCount = 0;
|
|
373
|
+
this.mcpServer.registerResource(
|
|
374
|
+
"object_list",
|
|
375
|
+
"objectstack://objects",
|
|
376
|
+
{
|
|
377
|
+
description: "List all data objects (tables) in the ObjectStack instance",
|
|
378
|
+
mimeType: "application/json"
|
|
379
|
+
},
|
|
380
|
+
async () => {
|
|
381
|
+
const objects = await metadataService.listObjects();
|
|
382
|
+
const summary = objects.map((o) => ({
|
|
383
|
+
name: o.name,
|
|
384
|
+
label: o.label ?? o.name,
|
|
385
|
+
fieldCount: o.fields ? Object.keys(o.fields).length : 0
|
|
386
|
+
}));
|
|
387
|
+
return {
|
|
388
|
+
contents: [{
|
|
389
|
+
uri: "objectstack://objects",
|
|
390
|
+
mimeType: "application/json",
|
|
391
|
+
text: JSON.stringify({ objects: summary, totalCount: summary.length }, null, 2)
|
|
392
|
+
}]
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
);
|
|
396
|
+
resourceCount++;
|
|
397
|
+
this.mcpServer.registerResource(
|
|
398
|
+
"object_schema",
|
|
399
|
+
new import_mcp.ResourceTemplate("objectstack://objects/{objectName}", { list: void 0 }),
|
|
400
|
+
{
|
|
401
|
+
description: "Get the full schema of a specific data object including fields and features",
|
|
402
|
+
mimeType: "application/json"
|
|
403
|
+
},
|
|
404
|
+
async (_uri, variables) => {
|
|
405
|
+
const objectName = String(variables.objectName);
|
|
406
|
+
const objectDef = await metadataService.getObject(objectName);
|
|
407
|
+
if (!objectDef) {
|
|
408
|
+
return {
|
|
409
|
+
contents: [{
|
|
410
|
+
uri: `objectstack://objects/${objectName}`,
|
|
411
|
+
mimeType: "application/json",
|
|
412
|
+
text: JSON.stringify({ error: `Object "${objectName}" not found` })
|
|
413
|
+
}]
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
const def = objectDef;
|
|
417
|
+
const fields = def.fields ?? {};
|
|
418
|
+
const fieldSummary = Object.entries(fields).map(([key, f]) => ({
|
|
419
|
+
name: key,
|
|
420
|
+
type: f.type,
|
|
421
|
+
label: f.label ?? key,
|
|
422
|
+
required: f.required ?? false
|
|
423
|
+
}));
|
|
424
|
+
return {
|
|
425
|
+
contents: [{
|
|
426
|
+
uri: `objectstack://objects/${objectName}`,
|
|
427
|
+
mimeType: "application/json",
|
|
428
|
+
text: JSON.stringify({
|
|
429
|
+
name: def.name,
|
|
430
|
+
label: def.label ?? def.name,
|
|
431
|
+
fields: fieldSummary,
|
|
432
|
+
enableFeatures: def.enable ?? {}
|
|
433
|
+
}, null, 2)
|
|
434
|
+
}]
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
);
|
|
438
|
+
resourceCount++;
|
|
439
|
+
if (dataEngine) {
|
|
440
|
+
this.mcpServer.registerResource(
|
|
441
|
+
"record_by_id",
|
|
442
|
+
new import_mcp.ResourceTemplate("objectstack://objects/{objectName}/records/{recordId}", { list: void 0 }),
|
|
443
|
+
{
|
|
444
|
+
description: "Get a specific record by ID from a data object",
|
|
445
|
+
mimeType: "application/json"
|
|
446
|
+
},
|
|
447
|
+
async (_uri, variables) => {
|
|
448
|
+
const objectName = String(variables.objectName);
|
|
449
|
+
const recordId = String(variables.recordId);
|
|
450
|
+
try {
|
|
451
|
+
const record = await dataEngine.findOne(objectName, {
|
|
452
|
+
where: { id: recordId }
|
|
453
|
+
});
|
|
454
|
+
if (!record) {
|
|
455
|
+
return {
|
|
456
|
+
contents: [{
|
|
457
|
+
uri: `objectstack://objects/${objectName}/records/${recordId}`,
|
|
458
|
+
mimeType: "application/json",
|
|
459
|
+
text: JSON.stringify({ error: `Record "${recordId}" not found in "${objectName}"` })
|
|
460
|
+
}]
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
contents: [{
|
|
465
|
+
uri: `objectstack://objects/${objectName}/records/${recordId}`,
|
|
466
|
+
mimeType: "application/json",
|
|
467
|
+
text: JSON.stringify(record, null, 2)
|
|
468
|
+
}]
|
|
469
|
+
};
|
|
470
|
+
} catch (err) {
|
|
471
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
472
|
+
return {
|
|
473
|
+
contents: [{
|
|
474
|
+
uri: `objectstack://objects/${objectName}/records/${recordId}`,
|
|
475
|
+
mimeType: "application/json",
|
|
476
|
+
text: JSON.stringify({ error: message })
|
|
477
|
+
}]
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
);
|
|
482
|
+
resourceCount++;
|
|
483
|
+
}
|
|
484
|
+
if (metadataService.getRegisteredTypes) {
|
|
485
|
+
this.mcpServer.registerResource(
|
|
486
|
+
"metadata_types",
|
|
487
|
+
"objectstack://metadata/types",
|
|
488
|
+
{
|
|
489
|
+
description: "List all registered metadata types (object, app, view, agent, tool, etc.)",
|
|
490
|
+
mimeType: "application/json"
|
|
491
|
+
},
|
|
492
|
+
async () => {
|
|
493
|
+
const types = await metadataService.getRegisteredTypes();
|
|
494
|
+
return {
|
|
495
|
+
contents: [{
|
|
496
|
+
uri: "objectstack://metadata/types",
|
|
497
|
+
mimeType: "application/json",
|
|
498
|
+
text: JSON.stringify({ types, totalCount: types.length }, null, 2)
|
|
499
|
+
}]
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
);
|
|
503
|
+
resourceCount++;
|
|
504
|
+
}
|
|
505
|
+
logger?.info(`[MCP] Bridged ${resourceCount} resource endpoints`);
|
|
506
|
+
}
|
|
507
|
+
// ── Prompt Bridge ──────────────────────────────────────────────
|
|
508
|
+
/**
|
|
509
|
+
* Bridge registered agents to MCP prompts.
|
|
510
|
+
*
|
|
511
|
+
* Each active agent becomes an MCP prompt with:
|
|
512
|
+
* - Name matching the agent name
|
|
513
|
+
* - System message from agent instructions
|
|
514
|
+
* - Optional context arguments (objectName, recordId, viewName)
|
|
515
|
+
*/
|
|
516
|
+
bridgePrompts(metadataService) {
|
|
517
|
+
const logger = this.config.logger;
|
|
518
|
+
this.mcpServer.registerPrompt(
|
|
519
|
+
"agent_prompt",
|
|
520
|
+
{
|
|
521
|
+
description: "Load an agent's system prompt with optional UI context. Use the agentName argument to select which agent's instructions to use.",
|
|
522
|
+
argsSchema: {
|
|
523
|
+
agentName: import_zod2.z.string().describe('Name of the agent to load (e.g. "data_chat", "metadata_assistant")'),
|
|
524
|
+
objectName: import_zod2.z.string().optional().describe("Current object the user is viewing"),
|
|
525
|
+
recordId: import_zod2.z.string().optional().describe("Currently selected record ID"),
|
|
526
|
+
viewName: import_zod2.z.string().optional().describe("Current view name")
|
|
527
|
+
}
|
|
528
|
+
},
|
|
529
|
+
async (args) => {
|
|
530
|
+
const agentName = String(args.agentName ?? "");
|
|
531
|
+
if (!agentName) {
|
|
532
|
+
return {
|
|
533
|
+
messages: [{
|
|
534
|
+
role: "user",
|
|
535
|
+
content: { type: "text", text: "Error: agentName argument is required" }
|
|
536
|
+
}]
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
const raw = await metadataService.get("agent", agentName);
|
|
540
|
+
if (!raw) {
|
|
541
|
+
return {
|
|
542
|
+
messages: [{
|
|
543
|
+
role: "user",
|
|
544
|
+
content: { type: "text", text: `Error: Agent "${agentName}" not found` }
|
|
545
|
+
}]
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
const agent = raw;
|
|
549
|
+
const parts = [];
|
|
550
|
+
parts.push(agent.instructions ?? "");
|
|
551
|
+
const contextHints = [];
|
|
552
|
+
if (args.objectName) contextHints.push(`Current object: ${args.objectName}`);
|
|
553
|
+
if (args.recordId) contextHints.push(`Selected record ID: ${args.recordId}`);
|
|
554
|
+
if (args.viewName) contextHints.push(`Current view: ${args.viewName}`);
|
|
555
|
+
if (contextHints.length > 0) {
|
|
556
|
+
parts.push("\n--- Current Context ---\n" + contextHints.join("\n"));
|
|
557
|
+
}
|
|
558
|
+
return {
|
|
559
|
+
messages: [{
|
|
560
|
+
role: "assistant",
|
|
561
|
+
content: { type: "text", text: parts.join("\n") }
|
|
562
|
+
}]
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
);
|
|
566
|
+
logger?.info("[MCP] Agent prompts bridged");
|
|
567
|
+
}
|
|
568
|
+
// ── Lifecycle ──────────────────────────────────────────────────
|
|
569
|
+
/**
|
|
570
|
+
* Start the MCP server with the configured transport.
|
|
571
|
+
*
|
|
572
|
+
* For stdio transport, this connects to process stdin/stdout.
|
|
573
|
+
*/
|
|
574
|
+
async start() {
|
|
575
|
+
if (this.started) return;
|
|
576
|
+
const logger = this.config.logger;
|
|
577
|
+
if (this.config.transport === "stdio") {
|
|
578
|
+
this.transport = new import_stdio.StdioServerTransport();
|
|
579
|
+
await this.mcpServer.connect(this.transport);
|
|
580
|
+
this.started = true;
|
|
581
|
+
logger?.info(`[MCP] Server started (transport: stdio, name: ${this.config.name})`);
|
|
582
|
+
} else {
|
|
583
|
+
logger?.info("[MCP] HTTP transport ready (served per-request at /api/v1/mcp).");
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Stop the MCP server and disconnect the transport.
|
|
588
|
+
*/
|
|
589
|
+
async stop() {
|
|
590
|
+
if (!this.started) return;
|
|
591
|
+
await this.mcpServer.close();
|
|
592
|
+
this.transport = void 0;
|
|
593
|
+
this.started = false;
|
|
594
|
+
this.config.logger?.info("[MCP] Server stopped");
|
|
595
|
+
}
|
|
596
|
+
// ── HTTP (Streamable HTTP) transport ───────────────────────────
|
|
597
|
+
/**
|
|
598
|
+
* Handle one MCP request over the **Streamable HTTP** transport (Web Standard
|
|
599
|
+
* `Request`/`Response`), the network-reachable surface for external agents.
|
|
600
|
+
*
|
|
601
|
+
* Stateless by design: a fresh {@link McpServer} + transport is built per
|
|
602
|
+
* request (the SDK-recommended pattern for stateless HTTP — it avoids any
|
|
603
|
+
* cross-request session/request-id collision and keeps each call isolated).
|
|
604
|
+
* The tool set is the object-CRUD bridge, bound to the **caller's principal**
|
|
605
|
+
* via `bridge`; the runtime wires that bridge to the existing permission +
|
|
606
|
+
* RLS path, so an external agent can never exceed the key's authority.
|
|
607
|
+
*
|
|
608
|
+
* Only the object-CRUD tools are exposed here — the internal AI/authoring
|
|
609
|
+
* toolRegistry (which can mutate metadata) is deliberately NOT bridged onto
|
|
610
|
+
* the external surface.
|
|
611
|
+
*
|
|
612
|
+
* @param request The inbound Web `Request` (headers/method/url).
|
|
613
|
+
* @param opts.bridge Principal-bound data accessor (required to expose tools).
|
|
614
|
+
* @param opts.parsedBody Pre-parsed JSON-RPC body (the dispatcher already read it).
|
|
615
|
+
* @param opts.authInfo Optional auth info forwarded to message handlers.
|
|
616
|
+
* @param opts.toolOptions Object-tool exposure options (system objects, limits).
|
|
617
|
+
*/
|
|
618
|
+
async handleHttpRequest(request, opts = {}) {
|
|
619
|
+
const server = new import_mcp.McpServer(
|
|
620
|
+
{ name: this.config.name, version: this.config.version },
|
|
621
|
+
{
|
|
622
|
+
capabilities: { tools: {} },
|
|
623
|
+
instructions: this.config.instructions ?? "ObjectStack MCP Server \u2014 query and modify your app's data objects as tools."
|
|
624
|
+
}
|
|
625
|
+
);
|
|
626
|
+
if (opts.bridge) {
|
|
627
|
+
registerObjectTools(server, opts.bridge, opts.toolOptions);
|
|
628
|
+
}
|
|
629
|
+
const transport = new import_webStandardStreamableHttp.WebStandardStreamableHTTPServerTransport({
|
|
630
|
+
// Stateless: no session id, single request/response.
|
|
631
|
+
sessionIdGenerator: void 0,
|
|
632
|
+
// Return a buffered JSON response (no long-lived SSE) — fits the
|
|
633
|
+
// Worker→container hop without streaming pass-through concerns.
|
|
634
|
+
enableJsonResponse: true
|
|
635
|
+
});
|
|
636
|
+
await server.connect(transport);
|
|
637
|
+
try {
|
|
638
|
+
return await transport.handleRequest(request, {
|
|
639
|
+
parsedBody: opts.parsedBody,
|
|
640
|
+
authInfo: opts.authInfo
|
|
641
|
+
});
|
|
642
|
+
} finally {
|
|
643
|
+
await server.close().catch(() => {
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
// src/plugin.ts
|
|
650
|
+
var MCPServerPlugin = class {
|
|
651
|
+
constructor(options = {}) {
|
|
652
|
+
this.name = "com.objectstack.mcp";
|
|
653
|
+
this.version = "1.0.0";
|
|
654
|
+
this.type = "standard";
|
|
655
|
+
this.dependencies = [];
|
|
656
|
+
this.options = options;
|
|
657
|
+
}
|
|
658
|
+
async init(ctx) {
|
|
659
|
+
const config = {
|
|
660
|
+
name: (0, import_types.readEnvWithDeprecation)("OS_MCP_SERVER_NAME", "MCP_SERVER_NAME") ?? this.options.name ?? "objectstack",
|
|
661
|
+
version: this.options.version ?? "1.0.0",
|
|
662
|
+
transport: (0, import_types.readEnvWithDeprecation)("OS_MCP_SERVER_TRANSPORT", "MCP_SERVER_TRANSPORT") ?? this.options.transport ?? "stdio",
|
|
663
|
+
instructions: this.options.instructions,
|
|
664
|
+
logger: ctx.logger
|
|
665
|
+
};
|
|
666
|
+
this.runtime = new MCPServerRuntime(config);
|
|
667
|
+
ctx.registerService("mcp", this.runtime);
|
|
668
|
+
ctx.logger.info("[MCP] Plugin initialized");
|
|
669
|
+
}
|
|
670
|
+
async start(ctx) {
|
|
671
|
+
if (!this.runtime) return;
|
|
672
|
+
try {
|
|
673
|
+
const aiService = ctx.getService("ai");
|
|
674
|
+
if (aiService?.toolRegistry) {
|
|
675
|
+
this.runtime.bridgeTools(aiService.toolRegistry);
|
|
676
|
+
} else {
|
|
677
|
+
ctx.logger.debug("[MCP] AI service does not expose a toolRegistry, skipping tool bridging");
|
|
678
|
+
}
|
|
679
|
+
} catch {
|
|
680
|
+
ctx.logger.debug("[MCP] AI service not available, skipping tool bridging");
|
|
681
|
+
}
|
|
682
|
+
let metadataService;
|
|
683
|
+
let dataEngine;
|
|
684
|
+
try {
|
|
685
|
+
metadataService = ctx.getService("metadata");
|
|
686
|
+
} catch {
|
|
687
|
+
ctx.logger.debug("[MCP] Metadata service not available, skipping resource bridging");
|
|
688
|
+
}
|
|
689
|
+
try {
|
|
690
|
+
dataEngine = ctx.getService("data");
|
|
691
|
+
} catch {
|
|
692
|
+
ctx.logger.debug("[MCP] Data engine not available, skipping record resources");
|
|
693
|
+
}
|
|
694
|
+
if (metadataService) {
|
|
695
|
+
this.runtime.bridgeResources(metadataService, dataEngine);
|
|
696
|
+
this.runtime.bridgePrompts(metadataService);
|
|
697
|
+
}
|
|
698
|
+
const shouldStart = this.options.autoStart || (0, import_types.readEnvWithDeprecation)("OS_MCP_SERVER_ENABLED", "MCP_SERVER_ENABLED") === "true";
|
|
699
|
+
if (shouldStart) {
|
|
700
|
+
await this.runtime.start();
|
|
701
|
+
ctx.logger.info("[MCP] Server started automatically");
|
|
702
|
+
} else {
|
|
703
|
+
ctx.logger.info(
|
|
704
|
+
"[MCP] Server ready but not started. Set OS_MCP_SERVER_ENABLED=true or use autoStart option."
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
await ctx.trigger("mcp:ready", this.runtime);
|
|
708
|
+
}
|
|
709
|
+
async destroy() {
|
|
710
|
+
if (this.runtime?.isStarted) {
|
|
711
|
+
await this.runtime.stop();
|
|
712
|
+
}
|
|
713
|
+
this.runtime = void 0;
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
// src/skill.ts
|
|
718
|
+
var OBJECTSTACK_SKILL_NAME = "objectstack";
|
|
719
|
+
var OBJECTSTACK_SKILL_DESCRIPTION = "Query and modify data in an ObjectStack app over MCP \u2014 discover objects, read and filter records, and create/update/delete under your own permissions and row-level security. Use when the user wants to inspect or change data in their ObjectStack environment.";
|
|
720
|
+
var URL_PLACEHOLDER = "<YOUR_ENV_MCP_URL>";
|
|
721
|
+
function renderSkillMarkdown(options = {}) {
|
|
722
|
+
const url = options.mcpUrl?.trim() || URL_PLACEHOLDER;
|
|
723
|
+
const envLabel = options.envName?.trim();
|
|
724
|
+
const intro = envLabel ? `This skill connects you to the **${envLabel}** ObjectStack environment.` : "This skill connects you to an ObjectStack environment.";
|
|
725
|
+
return `---
|
|
726
|
+
name: ${OBJECTSTACK_SKILL_NAME}
|
|
727
|
+
description: ${OBJECTSTACK_SKILL_DESCRIPTION}
|
|
728
|
+
---
|
|
729
|
+
|
|
730
|
+
# ObjectStack
|
|
731
|
+
|
|
732
|
+
${intro} An ObjectStack environment exposes its data **objects** (tables) as
|
|
733
|
+
tools over the Model Context Protocol (MCP). Every operation runs **as you** \u2014
|
|
734
|
+
under your account's permissions and row-level security \u2014 so you may see a
|
|
735
|
+
subset of rows, or get a permission error on a write. That is expected
|
|
736
|
+
governance, not a failure.
|
|
737
|
+
|
|
738
|
+
## When to use
|
|
739
|
+
|
|
740
|
+
Use these tools whenever the user wants to **inspect or change data** in their
|
|
741
|
+
ObjectStack app: look up records, filter/report, create or update entries, or
|
|
742
|
+
clean up data. Prefer these tools over guessing \u2014 the environment is the source
|
|
743
|
+
of truth.
|
|
744
|
+
|
|
745
|
+
## Connect
|
|
746
|
+
|
|
747
|
+
This skill drives the MCP server at:
|
|
748
|
+
|
|
749
|
+
\`\`\`
|
|
750
|
+
${url}
|
|
751
|
+
\`\`\`
|
|
752
|
+
|
|
753
|
+
Authenticate with an ObjectStack API key sent as a request header (the key is
|
|
754
|
+
shown to you once when created; treat it like a password):
|
|
755
|
+
|
|
756
|
+
\`\`\`
|
|
757
|
+
x-api-key: <YOUR_API_KEY>
|
|
758
|
+
\`\`\`
|
|
759
|
+
|
|
760
|
+
(The header \`Authorization: ApiKey <YOUR_API_KEY>\` is also accepted.) If your
|
|
761
|
+
MCP client supports custom headers on a remote server, set the header there.
|
|
762
|
+
|
|
763
|
+
## Discover before you act
|
|
764
|
+
|
|
765
|
+
The schema is **not** baked into this skill \u2014 it is discovered live, so it is
|
|
766
|
+
always current even as the app evolves:
|
|
767
|
+
|
|
768
|
+
1. \`list_objects\` \u2014 see what objects exist.
|
|
769
|
+
2. \`describe_object({ objectName })\` \u2014 get an object's fields (name, type,
|
|
770
|
+
required) before querying or writing it.
|
|
771
|
+
|
|
772
|
+
Always discover the relevant object's shape before constructing a filter or a
|
|
773
|
+
create/update payload.
|
|
774
|
+
|
|
775
|
+
## Tools
|
|
776
|
+
|
|
777
|
+
- **list_objects()** \u2014 list available objects (system \`sys_*\` objects are hidden).
|
|
778
|
+
- **describe_object({ objectName })** \u2014 an object's fields and features.
|
|
779
|
+
- **query_records({ objectName, where?, fields?, limit?, offset?, orderBy? })** \u2014
|
|
780
|
+
read records. \`where\` is a field\u2192value match, e.g. \`{ "status": "open" }\`.
|
|
781
|
+
Results are page-capped; use \`limit\`/\`offset\` to page.
|
|
782
|
+
- **get_record({ objectName, recordId })** \u2014 fetch one record by id.
|
|
783
|
+
- **create_record({ objectName, data })** \u2014 create a record.
|
|
784
|
+
- **update_record({ objectName, recordId, data })** \u2014 change fields on a record.
|
|
785
|
+
- **delete_record({ objectName, recordId })** \u2014 delete a record (destructive \u2014
|
|
786
|
+
confirm with the user first).
|
|
787
|
+
|
|
788
|
+
## Conventions & gotchas
|
|
789
|
+
|
|
790
|
+
- **Permissions/RLS apply to every call.** Fewer rows than expected, or a
|
|
791
|
+
write that's rejected, usually means your key isn't authorized \u2014 don't retry
|
|
792
|
+
blindly; tell the user.
|
|
793
|
+
- **Discover, don't assume.** Object and field names vary per app; always
|
|
794
|
+
\`list_objects\` / \`describe_object\` first.
|
|
795
|
+
- **Writes are real and immediate.** There is no implicit dry-run. Confirm
|
|
796
|
+
destructive actions (\`delete_record\`, bulk updates) with the user.
|
|
797
|
+
- **Page large reads.** Use \`limit\`/\`offset\` rather than asking for everything.
|
|
798
|
+
|
|
799
|
+
## Recommended workflow
|
|
800
|
+
|
|
801
|
+
1. \`list_objects\` to orient.
|
|
802
|
+
2. \`describe_object\` on the target object.
|
|
803
|
+
3. \`query_records\` to read / verify current state.
|
|
804
|
+
4. \`create_record\` / \`update_record\` / \`delete_record\` to make changes,
|
|
805
|
+
confirming destructive steps with the user.
|
|
806
|
+
`;
|
|
807
|
+
}
|
|
808
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
809
|
+
0 && (module.exports = {
|
|
810
|
+
MCPServerPlugin,
|
|
811
|
+
MCPServerRuntime,
|
|
812
|
+
OBJECTSTACK_SKILL_DESCRIPTION,
|
|
813
|
+
OBJECTSTACK_SKILL_NAME,
|
|
814
|
+
registerObjectTools,
|
|
815
|
+
renderSkillMarkdown
|
|
816
|
+
});
|
|
817
|
+
//# sourceMappingURL=index.cjs.map
|