stackby-mcp-server 0.2.8 → 0.3.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/README.md +55 -1
- package/dist/index.js +4 -547
- package/dist/list-workspaces.d.ts +1 -0
- package/dist/list-workspaces.js +31 -0
- package/dist/mcp-server.d.ts +5 -0
- package/dist/mcp-server.js +549 -0
- package/dist/request-context.d.ts +8 -0
- package/dist/request-context.js +21 -0
- package/dist/server-http.d.ts +1 -0
- package/dist/server-http.js +105 -0
- package/dist/stackby-api.d.ts +0 -4
- package/dist/stackby-api.js +22 -7
- package/package.json +2 -1
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared MCP server and tool registration (used by stdio and HTTP entry points).
|
|
3
|
+
*/
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { hasApiKey, getApiBaseUrl, getWorkspaces, getAllStacks, getTables, getTableViewList, describeTable, getRowList, searchRecords, getRecord, createRow, updateRows, deleteRows, createTable, createColumn, } from "./stackby-api.js";
|
|
7
|
+
export function createStackbyMcpServer() {
|
|
8
|
+
const mcpServer = new McpServer({
|
|
9
|
+
name: "stackby-mcp-server",
|
|
10
|
+
version: "0.1.0",
|
|
11
|
+
});
|
|
12
|
+
mcpServer.registerTool("list_workspaces", {
|
|
13
|
+
description: "List Stackby workspaces the user can access. Requires STACKBY_API_KEY (or PAT) in MCP config.",
|
|
14
|
+
inputSchema: {},
|
|
15
|
+
}, async () => {
|
|
16
|
+
if (!hasApiKey()) {
|
|
17
|
+
return {
|
|
18
|
+
content: [
|
|
19
|
+
{
|
|
20
|
+
type: "text",
|
|
21
|
+
text: "STACKBY_API_KEY is not set. Add it to your MCP config (e.g. in Cursor: .cursor/mcp.json → env.STACKBY_API_KEY) with your Stackby API key or Personal Access Token (PAT).",
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const workspaces = await getWorkspaces();
|
|
28
|
+
const lines = workspaces.length === 0
|
|
29
|
+
? ["No workspaces found."]
|
|
30
|
+
: workspaces.map((w) => `- ${w.name} (id: ${w.id})`);
|
|
31
|
+
return {
|
|
32
|
+
content: [
|
|
33
|
+
{
|
|
34
|
+
type: "text",
|
|
35
|
+
text: `Workspaces (${workspaces.length}):\n${lines.join("\n")}`,
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
42
|
+
return {
|
|
43
|
+
content: [
|
|
44
|
+
{
|
|
45
|
+
type: "text",
|
|
46
|
+
text: `Failed to list workspaces: ${message}. STACKBY_API_KEY and STACKBY_API_URL in use: ${getApiBaseUrl()}.`,
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
isError: true,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
mcpServer.registerTool("list_stacks", {
|
|
54
|
+
description: "List Stackby stacks (bases) the user can access. Requires STACKBY_API_KEY (or PAT) in MCP config.",
|
|
55
|
+
inputSchema: {},
|
|
56
|
+
}, async () => {
|
|
57
|
+
if (!hasApiKey()) {
|
|
58
|
+
return {
|
|
59
|
+
content: [
|
|
60
|
+
{
|
|
61
|
+
type: "text",
|
|
62
|
+
text: "STACKBY_API_KEY is not set. Add it to your MCP config (e.g. in Cursor: .cursor/mcp.json → env.STACKBY_API_KEY) with your Stackby API key or Personal Access Token (PAT).",
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const stacks = await getAllStacks();
|
|
69
|
+
const lines = stacks.length === 0
|
|
70
|
+
? ["No stacks found."]
|
|
71
|
+
: stacks.map((s) => `- ${s.stackName} (id: ${s.stackId}, workspace: ${s.workspaceName ?? s.workspaceId})`);
|
|
72
|
+
return {
|
|
73
|
+
content: [
|
|
74
|
+
{
|
|
75
|
+
type: "text",
|
|
76
|
+
text: `Stacks (${stacks.length}):\n${lines.join("\n")}`,
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
83
|
+
return {
|
|
84
|
+
content: [
|
|
85
|
+
{
|
|
86
|
+
type: "text",
|
|
87
|
+
text: `Failed to list stacks: ${message}. STACKBY_API_KEY and STACKBY_API_URL in use: ${getApiBaseUrl()}.`,
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
isError: true,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
mcpServer.registerTool("list_tables", {
|
|
95
|
+
description: "List tables in a Stackby stack. Use list_stacks first to get stack IDs.",
|
|
96
|
+
inputSchema: {
|
|
97
|
+
stackId: z.string().describe("Stack ID (from list_stacks)"),
|
|
98
|
+
},
|
|
99
|
+
}, async ({ stackId }) => {
|
|
100
|
+
const id = stackId?.trim();
|
|
101
|
+
if (!id) {
|
|
102
|
+
return {
|
|
103
|
+
content: [{ type: "text", text: "stackId is required. Use list_stacks to get stack IDs." }],
|
|
104
|
+
isError: true,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (!hasApiKey()) {
|
|
108
|
+
return {
|
|
109
|
+
content: [{ type: "text", text: "STACKBY_API_KEY is not set. Add it to your MCP config." }],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const tables = await getTables(id);
|
|
114
|
+
const lines = tables.length === 0
|
|
115
|
+
? ["No tables found in this stack."]
|
|
116
|
+
: tables.map((t) => `- ${t.name} (id: ${t.id})`);
|
|
117
|
+
return {
|
|
118
|
+
content: [
|
|
119
|
+
{
|
|
120
|
+
type: "text",
|
|
121
|
+
text: `Tables in stack ${id} (${tables.length}):\n${lines.join("\n")}`,
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
128
|
+
return {
|
|
129
|
+
content: [
|
|
130
|
+
{
|
|
131
|
+
type: "text",
|
|
132
|
+
text: `Failed to list tables: ${message}. Check stackId and API access.`,
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
isError: true,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
mcpServer.registerTool("describe_table", {
|
|
140
|
+
description: "Get table schema: name, fields (columns with id, name, type), and views. Use list_tables to get stackId and tableId.",
|
|
141
|
+
inputSchema: {
|
|
142
|
+
stackId: z.string().describe("Stack ID (from list_stacks)"),
|
|
143
|
+
tableId: z.string().describe("Table ID (from list_tables)"),
|
|
144
|
+
},
|
|
145
|
+
}, async ({ stackId, tableId }) => {
|
|
146
|
+
const sId = stackId?.trim();
|
|
147
|
+
const tId = tableId?.trim();
|
|
148
|
+
if (!sId || !tId) {
|
|
149
|
+
return {
|
|
150
|
+
content: [{ type: "text", text: "stackId and tableId are required. Use list_stacks and list_tables to get IDs." }],
|
|
151
|
+
isError: true,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (!hasApiKey()) {
|
|
155
|
+
return {
|
|
156
|
+
content: [{ type: "text", text: "STACKBY_API_KEY is not set. Add it to your MCP config." }],
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const schema = await describeTable(sId, tId);
|
|
161
|
+
const fieldLines = schema.fields.length === 0
|
|
162
|
+
? ["(no fields)"]
|
|
163
|
+
: schema.fields.map((f) => ` - ${f.name} (id: ${f.id}, type: ${f.type})`);
|
|
164
|
+
const viewLines = schema.views.length === 0
|
|
165
|
+
? ["(no views)"]
|
|
166
|
+
: schema.views.map((v) => ` - ${v.name} (id: ${v.id})`);
|
|
167
|
+
const text = [
|
|
168
|
+
`Table: ${schema.name} (id: ${schema.id})`,
|
|
169
|
+
"",
|
|
170
|
+
"Fields:",
|
|
171
|
+
...fieldLines,
|
|
172
|
+
"",
|
|
173
|
+
"Views:",
|
|
174
|
+
...viewLines,
|
|
175
|
+
].join("\n");
|
|
176
|
+
return {
|
|
177
|
+
content: [{ type: "text", text }],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
182
|
+
return {
|
|
183
|
+
content: [
|
|
184
|
+
{ type: "text", text: `Failed to describe table: ${message}. Check stackId, tableId, and API access.` },
|
|
185
|
+
],
|
|
186
|
+
isError: true,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
mcpServer.registerTool("list_records", {
|
|
191
|
+
description: "List rows (records) in a table. Use list_stacks and list_tables to get stackId and tableId.",
|
|
192
|
+
inputSchema: {
|
|
193
|
+
stackId: z.string().describe("Stack ID (from list_stacks)"),
|
|
194
|
+
tableId: z.string().describe("Table ID (from list_tables)"),
|
|
195
|
+
maxRecords: z.number().optional().describe("Max records to return (1–100, default 100)"),
|
|
196
|
+
},
|
|
197
|
+
}, async ({ stackId, tableId, maxRecords }) => {
|
|
198
|
+
const sId = stackId?.trim();
|
|
199
|
+
const tId = tableId?.trim();
|
|
200
|
+
if (!sId || !tId) {
|
|
201
|
+
return {
|
|
202
|
+
content: [{ type: "text", text: "stackId and tableId are required. Use list_stacks and list_tables to get IDs." }],
|
|
203
|
+
isError: true,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (!hasApiKey()) {
|
|
207
|
+
return {
|
|
208
|
+
content: [{ type: "text", text: "STACKBY_API_KEY is not set. Add it to your MCP config." }],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
const records = await getRowList(sId, tId, { maxRecords: maxRecords ?? 100 });
|
|
213
|
+
const lines = records.length === 0
|
|
214
|
+
? ["No records found."]
|
|
215
|
+
: records.map((r) => `- id: ${r.id} | ${JSON.stringify(r.field)}`);
|
|
216
|
+
const text = [`Records in table ${tId} (${records.length}):`, "", ...lines].join("\n");
|
|
217
|
+
return {
|
|
218
|
+
content: [{ type: "text", text }],
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
223
|
+
return {
|
|
224
|
+
content: [
|
|
225
|
+
{ type: "text", text: `Failed to list records: ${message}. Check stackId, tableId, and API access.` },
|
|
226
|
+
],
|
|
227
|
+
isError: true,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
mcpServer.registerTool("search_records", {
|
|
232
|
+
description: "Search for rows containing text in a table. Uses first column if fieldIds not provided. Use list_stacks and list_tables to get IDs.",
|
|
233
|
+
inputSchema: {
|
|
234
|
+
stackId: z.string().describe("Stack ID (from list_stacks)"),
|
|
235
|
+
tableId: z.string().describe("Table ID (from list_tables)"),
|
|
236
|
+
searchTerm: z.string().describe("Text to search for"),
|
|
237
|
+
fieldIds: z.array(z.string()).optional().describe("Optional column IDs to search in (uses first column if omitted)"),
|
|
238
|
+
maxRecords: z.number().optional().describe("Max records to return (default 100)"),
|
|
239
|
+
},
|
|
240
|
+
}, async ({ stackId, tableId, searchTerm, fieldIds, maxRecords }) => {
|
|
241
|
+
const sId = stackId?.trim();
|
|
242
|
+
const tId = tableId?.trim();
|
|
243
|
+
const term = searchTerm?.trim();
|
|
244
|
+
if (!sId || !tId || term === undefined || term === "") {
|
|
245
|
+
return {
|
|
246
|
+
content: [{ type: "text", text: "stackId, tableId, and searchTerm are required." }],
|
|
247
|
+
isError: true,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
if (!hasApiKey()) {
|
|
251
|
+
return {
|
|
252
|
+
content: [{ type: "text", text: "STACKBY_API_KEY is not set. Add it to your MCP config." }],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
const columnId = fieldIds && fieldIds.length > 0 ? fieldIds[0] : undefined;
|
|
257
|
+
const result = await searchRecords(sId, tId, term, { columnId, maxRecords });
|
|
258
|
+
const count = result.rowIds.length;
|
|
259
|
+
const lines = count === 0
|
|
260
|
+
? ["No matching records."]
|
|
261
|
+
: result.rowIds.map((id, i) => `- id: ${id} | ${(result.rowname && result.rowname[i]) || ""}`);
|
|
262
|
+
const text = [`Search "${term}" in table ${tId} (${count} match${count !== 1 ? "es" : ""}):`, "", ...lines].join("\n");
|
|
263
|
+
return {
|
|
264
|
+
content: [{ type: "text", text }],
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
269
|
+
return {
|
|
270
|
+
content: [
|
|
271
|
+
{ type: "text", text: `Failed to search records: ${message}. Check stackId, tableId, and API access.` },
|
|
272
|
+
],
|
|
273
|
+
isError: true,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
mcpServer.registerTool("get_record", {
|
|
278
|
+
description: "Get a single row (record) by id. Use list_records or search_records to get record IDs.",
|
|
279
|
+
inputSchema: {
|
|
280
|
+
stackId: z.string().describe("Stack ID (from list_stacks)"),
|
|
281
|
+
tableId: z.string().describe("Table ID (from list_tables)"),
|
|
282
|
+
recordId: z.string().describe("Record (row) ID"),
|
|
283
|
+
},
|
|
284
|
+
}, async ({ stackId, tableId, recordId }) => {
|
|
285
|
+
const sId = stackId?.trim();
|
|
286
|
+
const tId = tableId?.trim();
|
|
287
|
+
const rId = recordId?.trim();
|
|
288
|
+
if (!sId || !tId || !rId) {
|
|
289
|
+
return {
|
|
290
|
+
content: [{ type: "text", text: "stackId, tableId, and recordId are required." }],
|
|
291
|
+
isError: true,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if (!hasApiKey()) {
|
|
295
|
+
return {
|
|
296
|
+
content: [{ type: "text", text: "STACKBY_API_KEY is not set. Add it to your MCP config." }],
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
const record = await getRecord(sId, tId, rId);
|
|
301
|
+
if (!record) {
|
|
302
|
+
return {
|
|
303
|
+
content: [{ type: "text", text: `No record found with id ${rId} in table ${tId}.` }],
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
const text = [`Record ${record.id}:`, "", JSON.stringify(record.field, null, 2)].join("\n");
|
|
307
|
+
return {
|
|
308
|
+
content: [{ type: "text", text }],
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
313
|
+
return {
|
|
314
|
+
content: [
|
|
315
|
+
{ type: "text", text: `Failed to get record: ${message}. Check stackId, tableId, recordId, and API access.` },
|
|
316
|
+
],
|
|
317
|
+
isError: true,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
mcpServer.registerTool("create_record", {
|
|
322
|
+
description: "Create a new row (record) in a table. Use describe_table to get column names. Fields are keyed by column name.",
|
|
323
|
+
inputSchema: {
|
|
324
|
+
stackId: z.string().describe("Stack ID (from list_stacks)"),
|
|
325
|
+
tableId: z.string().describe("Table ID (from list_tables)"),
|
|
326
|
+
fields: z.record(z.string(), z.unknown()).describe("Field values keyed by column name (e.g. { \"Name\": \"Task 1\", \"Status\": \"Done\" })"),
|
|
327
|
+
},
|
|
328
|
+
}, async ({ stackId, tableId, fields }) => {
|
|
329
|
+
const sId = stackId?.trim();
|
|
330
|
+
const tId = tableId?.trim();
|
|
331
|
+
if (!sId || !tId) {
|
|
332
|
+
return {
|
|
333
|
+
content: [{ type: "text", text: "stackId and tableId are required." }],
|
|
334
|
+
isError: true,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
if (!hasApiKey()) {
|
|
338
|
+
return {
|
|
339
|
+
content: [{ type: "text", text: "STACKBY_API_KEY is not set. Add it to your MCP config." }],
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) {
|
|
343
|
+
return {
|
|
344
|
+
content: [{ type: "text", text: "fields must be an object of column names to values." }],
|
|
345
|
+
isError: true,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
const records = await createRow(sId, tId, fields);
|
|
350
|
+
const created = records[0];
|
|
351
|
+
if (!created) {
|
|
352
|
+
return {
|
|
353
|
+
content: [{ type: "text", text: "No record was created. Check table schema and field names." }],
|
|
354
|
+
isError: true,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
const text = [`Created record: ${created.id}`, "", JSON.stringify(created.field, null, 2)].join("\n");
|
|
358
|
+
return {
|
|
359
|
+
content: [{ type: "text", text }],
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
catch (err) {
|
|
363
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
364
|
+
return {
|
|
365
|
+
content: [
|
|
366
|
+
{ type: "text", text: `Failed to create record: ${message}. Check stackId, tableId, and field names (use describe_table).` },
|
|
367
|
+
],
|
|
368
|
+
isError: true,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
mcpServer.registerTool("update_records", {
|
|
373
|
+
description: "Update existing rows. Provide an array of { id, fields }. At most 10 records per request. Use describe_table for column names.",
|
|
374
|
+
inputSchema: {
|
|
375
|
+
stackId: z.string().describe("Stack ID (from list_stacks)"),
|
|
376
|
+
tableId: z.string().describe("Table ID (from list_tables)"),
|
|
377
|
+
records: z
|
|
378
|
+
.array(z.object({
|
|
379
|
+
id: z.string().describe("Record (row) ID"),
|
|
380
|
+
fields: z.record(z.string(), z.unknown()).describe("Field values to set (column name -> value)"),
|
|
381
|
+
}))
|
|
382
|
+
.min(1)
|
|
383
|
+
.max(10)
|
|
384
|
+
.describe("Records to update"),
|
|
385
|
+
},
|
|
386
|
+
}, async ({ stackId, tableId, records }) => {
|
|
387
|
+
const sId = stackId?.trim();
|
|
388
|
+
const tId = tableId?.trim();
|
|
389
|
+
if (!sId || !tId) {
|
|
390
|
+
return {
|
|
391
|
+
content: [{ type: "text", text: "stackId and tableId are required." }],
|
|
392
|
+
isError: true,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
if (!hasApiKey()) {
|
|
396
|
+
return {
|
|
397
|
+
content: [{ type: "text", text: "STACKBY_API_KEY is not set. Add it to your MCP config." }],
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
const updated = await updateRows(sId, tId, records.map((r) => ({ id: r.id, fields: r.fields })));
|
|
402
|
+
const lines = updated.map((r) => `- ${r.id}: ${JSON.stringify(r.field)}`);
|
|
403
|
+
const text = [`Updated ${updated.length} record(s):`, "", ...lines].join("\n");
|
|
404
|
+
return {
|
|
405
|
+
content: [{ type: "text", text }],
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
410
|
+
return {
|
|
411
|
+
content: [
|
|
412
|
+
{ type: "text", text: `Failed to update records: ${message}. Check stackId, tableId, record IDs, and field names.` },
|
|
413
|
+
],
|
|
414
|
+
isError: true,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
mcpServer.registerTool("delete_records", {
|
|
419
|
+
description: "Soft-delete rows (records) by ID. At most 10 per request. Use list_records or search_records to get IDs.",
|
|
420
|
+
inputSchema: {
|
|
421
|
+
stackId: z.string().describe("Stack ID (from list_stacks)"),
|
|
422
|
+
tableId: z.string().describe("Table ID (from list_tables)"),
|
|
423
|
+
recordIds: z.array(z.string()).min(1).max(10).describe("Record (row) IDs to delete"),
|
|
424
|
+
},
|
|
425
|
+
}, async ({ stackId, tableId, recordIds }) => {
|
|
426
|
+
const sId = stackId?.trim();
|
|
427
|
+
const tId = tableId?.trim();
|
|
428
|
+
if (!sId || !tId) {
|
|
429
|
+
return {
|
|
430
|
+
content: [{ type: "text", text: "stackId and tableId are required." }],
|
|
431
|
+
isError: true,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
if (!hasApiKey()) {
|
|
435
|
+
return {
|
|
436
|
+
content: [{ type: "text", text: "STACKBY_API_KEY is not set. Add it to your MCP config." }],
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
const result = await deleteRows(sId, tId, recordIds);
|
|
441
|
+
const list = result?.records ?? [];
|
|
442
|
+
const lines = list.map((r) => `- ${r.id}: deleted=${r.deleted}`);
|
|
443
|
+
const text = [`Deleted ${list.length} record(s):`, "", ...lines].join("\n");
|
|
444
|
+
return {
|
|
445
|
+
content: [{ type: "text", text }],
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
catch (err) {
|
|
449
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
450
|
+
return {
|
|
451
|
+
content: [
|
|
452
|
+
{ type: "text", text: `Failed to delete records: ${message}. Check stackId, tableId, and record IDs.` },
|
|
453
|
+
],
|
|
454
|
+
isError: true,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
mcpServer.registerTool("create_table", {
|
|
459
|
+
description: "Create a new table in a stack. Use list_stacks to get stackId.",
|
|
460
|
+
inputSchema: {
|
|
461
|
+
stackId: z.string().describe("Stack ID (from list_stacks)"),
|
|
462
|
+
name: z.string().describe("Table name"),
|
|
463
|
+
},
|
|
464
|
+
}, async ({ stackId, name }) => {
|
|
465
|
+
const sId = stackId?.trim();
|
|
466
|
+
const tableName = name?.trim();
|
|
467
|
+
if (!sId || !tableName) {
|
|
468
|
+
return {
|
|
469
|
+
content: [{ type: "text", text: "stackId and name are required." }],
|
|
470
|
+
isError: true,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
if (!hasApiKey()) {
|
|
474
|
+
return {
|
|
475
|
+
content: [{ type: "text", text: "STACKBY_API_KEY is not set. Add it to your MCP config." }],
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
try {
|
|
479
|
+
const result = await createTable(sId, tableName);
|
|
480
|
+
const id = result?.tableId ?? result?.id ?? "unknown";
|
|
481
|
+
const text = [`Created table: ${tableName}`, `Table ID: ${id}`].join("\n");
|
|
482
|
+
return {
|
|
483
|
+
content: [{ type: "text", text }],
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
catch (err) {
|
|
487
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
488
|
+
return {
|
|
489
|
+
content: [
|
|
490
|
+
{ type: "text", text: `Failed to create table: ${message}. Check stackId and plan limits.` },
|
|
491
|
+
],
|
|
492
|
+
isError: true,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
mcpServer.registerTool("create_field", {
|
|
497
|
+
description: "Create a new column (field) in a table. Use describe_table to see existing columns. For singleOption/multipleOptions pass options array.",
|
|
498
|
+
inputSchema: {
|
|
499
|
+
stackId: z.string().describe("Stack ID (from list_stacks)"),
|
|
500
|
+
tableId: z.string().describe("Table ID (from list_tables)"),
|
|
501
|
+
name: z.string().describe("Column name"),
|
|
502
|
+
columnType: z.string().describe("Column type: shortText, longText, number, checkbox, dateAndTime, singleOption, multipleOptions, email, url, etc."),
|
|
503
|
+
viewId: z.string().optional().describe("View ID (optional; first view used if omitted)"),
|
|
504
|
+
options: z.array(z.string()).optional().describe("For singleOption/multipleOptions: choice labels"),
|
|
505
|
+
},
|
|
506
|
+
}, async ({ stackId, tableId, name, columnType, viewId, options }) => {
|
|
507
|
+
const sId = stackId?.trim();
|
|
508
|
+
const tId = tableId?.trim();
|
|
509
|
+
const colName = name?.trim();
|
|
510
|
+
const type = columnType?.trim();
|
|
511
|
+
if (!sId || !tId || !colName || !type) {
|
|
512
|
+
return {
|
|
513
|
+
content: [{ type: "text", text: "stackId, tableId, name, and columnType are required." }],
|
|
514
|
+
isError: true,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
if (!hasApiKey()) {
|
|
518
|
+
return {
|
|
519
|
+
content: [{ type: "text", text: "STACKBY_API_KEY is not set. Add it to your MCP config." }],
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
try {
|
|
523
|
+
let viewIdToUse = viewId?.trim();
|
|
524
|
+
if (!viewIdToUse) {
|
|
525
|
+
const views = await getTableViewList(sId, tId);
|
|
526
|
+
viewIdToUse = views.length > 0 ? views[0].id : "";
|
|
527
|
+
}
|
|
528
|
+
const result = await createColumn(sId, tId, colName, type, {
|
|
529
|
+
viewId: viewIdToUse,
|
|
530
|
+
options: options && options.length > 0 ? options : undefined,
|
|
531
|
+
});
|
|
532
|
+
const id = result?.columnId ?? result?.id ?? "unknown";
|
|
533
|
+
const text = [`Created column: ${colName}`, `Column ID: ${id}`, `Type: ${type}`].join("\n");
|
|
534
|
+
return {
|
|
535
|
+
content: [{ type: "text", text }],
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
catch (err) {
|
|
539
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
540
|
+
return {
|
|
541
|
+
content: [
|
|
542
|
+
{ type: "text", text: `Failed to create field: ${message}. Check stackId, tableId, name, columnType (use describe_table for types).` },
|
|
543
|
+
],
|
|
544
|
+
isError: true,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
return mcpServer;
|
|
549
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface RequestContext {
|
|
2
|
+
apiKey: string;
|
|
3
|
+
apiUrl?: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function getRequestContext(): RequestContext | undefined;
|
|
6
|
+
export declare function runWithRequestContext<T>(context: RequestContext, fn: () => T): T;
|
|
7
|
+
export declare function getApiKeyFromContext(): string | undefined;
|
|
8
|
+
export declare function getApiUrlFromContext(): string | undefined;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request context for hosted (HTTP) mode.
|
|
3
|
+
* When the MCP server is used over HTTP, each request carries the user's API key in headers.
|
|
4
|
+
* We store it here so stackby-api can use it for that request only (stdio mode keeps using process.env).
|
|
5
|
+
*/
|
|
6
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
7
|
+
const requestContextStorage = new AsyncLocalStorage();
|
|
8
|
+
export function getRequestContext() {
|
|
9
|
+
return requestContextStorage.getStore();
|
|
10
|
+
}
|
|
11
|
+
export function runWithRequestContext(context, fn) {
|
|
12
|
+
return requestContextStorage.run(context, fn);
|
|
13
|
+
}
|
|
14
|
+
export function getApiKeyFromContext() {
|
|
15
|
+
const ctx = getRequestContext();
|
|
16
|
+
return ctx?.apiKey?.trim() || undefined;
|
|
17
|
+
}
|
|
18
|
+
export function getApiUrlFromContext() {
|
|
19
|
+
const ctx = getRequestContext();
|
|
20
|
+
return ctx?.apiUrl?.trim() || undefined;
|
|
21
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stackby MCP Server — HTTP entry point for hosted mode (e.g. ChatGPT, ALB).
|
|
3
|
+
* Per-request API key via X-Stackby-API-Key or Authorization: Bearer <key>.
|
|
4
|
+
* GET /health for ALB health checks; POST /mcp and GET /mcp for MCP.
|
|
5
|
+
*/
|
|
6
|
+
import * as http from "node:http";
|
|
7
|
+
import { createStackbyMcpServer } from "./mcp-server.js";
|
|
8
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
9
|
+
import { runWithRequestContext } from "./request-context.js";
|
|
10
|
+
const PORT = Number(process.env.PORT) || 3001;
|
|
11
|
+
const MCP_PATH = "/mcp";
|
|
12
|
+
const HEALTH_PATH = "/health";
|
|
13
|
+
/** Normalize path for comparison (lowercase, no trailing slash). */
|
|
14
|
+
function normalizePath(raw) {
|
|
15
|
+
const p = (raw || "").split("?")[0].trim().toLowerCase();
|
|
16
|
+
return p.endsWith("/") && p.length > 1 ? p.slice(0, -1) : p;
|
|
17
|
+
}
|
|
18
|
+
async function readBody(req) {
|
|
19
|
+
const chunks = [];
|
|
20
|
+
for await (const chunk of req) {
|
|
21
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
22
|
+
}
|
|
23
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
24
|
+
if (!raw.trim())
|
|
25
|
+
return undefined;
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(raw);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function getApiKeyFromRequest(req) {
|
|
34
|
+
const header = req.headers["x-stackby-api-key"];
|
|
35
|
+
if (typeof header === "string" && header.trim())
|
|
36
|
+
return header.trim();
|
|
37
|
+
const auth = req.headers.authorization;
|
|
38
|
+
if (typeof auth === "string" && auth.startsWith("Bearer "))
|
|
39
|
+
return auth.slice(7).trim();
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
function getApiUrlFromRequest(req) {
|
|
43
|
+
const header = req.headers["x-stackby-api-url"];
|
|
44
|
+
return typeof header === "string" && header.trim() ? header.trim() : undefined;
|
|
45
|
+
}
|
|
46
|
+
async function main() {
|
|
47
|
+
// Log unhandled rejections (e.g. from SDK after response started) so hosted logs show the real error
|
|
48
|
+
process.on("unhandledRejection", (reason, promise) => {
|
|
49
|
+
console.error("[MCP] Unhandled rejection:", reason);
|
|
50
|
+
});
|
|
51
|
+
const mcpServer = createStackbyMcpServer();
|
|
52
|
+
const transport = new StreamableHTTPServerTransport({
|
|
53
|
+
sessionIdGenerator: undefined, // stateless for hosted
|
|
54
|
+
});
|
|
55
|
+
await mcpServer.connect(transport);
|
|
56
|
+
const server = http.createServer(async (req, res) => {
|
|
57
|
+
const url = req.url ?? "";
|
|
58
|
+
const path = normalizePath(url);
|
|
59
|
+
if (path === HEALTH_PATH && req.method === "GET") {
|
|
60
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
61
|
+
res.end("OK");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (path === MCP_PATH && (req.method === "POST" || req.method === "GET")) {
|
|
65
|
+
const apiKey = getApiKeyFromRequest(req);
|
|
66
|
+
const apiUrl = getApiUrlFromRequest(req);
|
|
67
|
+
if (!apiKey) {
|
|
68
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
69
|
+
res.end(JSON.stringify({ error: "Missing API key. Send X-Stackby-API-Key or Authorization: Bearer <key>." }));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
let parsedBody;
|
|
73
|
+
try {
|
|
74
|
+
if (req.method === "POST") {
|
|
75
|
+
parsedBody = await readBody(req);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
parsedBody = undefined;
|
|
79
|
+
}
|
|
80
|
+
await runWithRequestContext({ apiKey, apiUrl }, () => transport.handleRequest(req, res, parsedBody));
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
84
|
+
console.error("[MCP /mcp] Error handling request:", err);
|
|
85
|
+
if (!res.headersSent) {
|
|
86
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
87
|
+
res.end(JSON.stringify({ error: "MCP handler error", message }));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
93
|
+
res.end("Not Found");
|
|
94
|
+
});
|
|
95
|
+
server.listen(PORT, () => {
|
|
96
|
+
console.log(`Stackby MCP HTTP server listening on port ${PORT}`);
|
|
97
|
+
console.log(` GET ${HEALTH_PATH} — health check`);
|
|
98
|
+
console.log(` POST ${MCP_PATH} — MCP (send X-Stackby-API-Key or Authorization: Bearer <key>)`);
|
|
99
|
+
console.log(` GET ${MCP_PATH} — MCP SSE`);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
main().catch((err) => {
|
|
103
|
+
console.error(err);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
});
|