@opensaas/stack-cli 0.1.2 → 0.1.4

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.
@@ -1,393 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
- import { generateMcp } from './mcp.js'
3
- import type { OpenSaasConfig } from '@opensaas/stack-core'
4
- import { text } from '@opensaas/stack-core/fields'
5
- import * as fs from 'fs'
6
- import * as path from 'path'
7
- import * as os from 'os'
8
-
9
- describe('MCP Generator', () => {
10
- let tempDir: string
11
-
12
- beforeEach(() => {
13
- // Create a temporary directory for each test
14
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-test-'))
15
- })
16
-
17
- afterEach(() => {
18
- // Clean up temporary directory
19
- if (fs.existsSync(tempDir)) {
20
- fs.rmSync(tempDir, { recursive: true, force: true })
21
- }
22
- })
23
-
24
- describe('generateMcp', () => {
25
- it('should return false when MCP is not enabled', () => {
26
- const config: OpenSaasConfig = {
27
- db: {
28
- provider: 'sqlite',
29
- url: 'file:./dev.db',
30
- },
31
- lists: {
32
- User: {
33
- fields: {
34
- name: text(),
35
- },
36
- },
37
- },
38
- }
39
-
40
- const generated = generateMcp(config, tempDir)
41
-
42
- expect(generated).toBe(false)
43
- })
44
-
45
- it('should return true when MCP is enabled', () => {
46
- const config: OpenSaasConfig = {
47
- db: {
48
- provider: 'sqlite',
49
- url: 'file:./dev.db',
50
- },
51
- mcp: {
52
- enabled: true,
53
- },
54
- lists: {
55
- User: {
56
- fields: {
57
- name: text(),
58
- },
59
- },
60
- },
61
- }
62
-
63
- const generated = generateMcp(config, tempDir)
64
-
65
- expect(generated).toBe(true)
66
- })
67
-
68
- it('should create MCP directory when enabled', () => {
69
- const config: OpenSaasConfig = {
70
- db: {
71
- provider: 'sqlite',
72
- url: 'file:./dev.db',
73
- },
74
- mcp: {
75
- enabled: true,
76
- },
77
- lists: {},
78
- }
79
-
80
- generateMcp(config, tempDir)
81
-
82
- const mcpDir = path.join(tempDir, '.opensaas', 'mcp')
83
- expect(fs.existsSync(mcpDir)).toBe(true)
84
- })
85
-
86
- it('should generate tools.json with default CRUD tools', () => {
87
- const config: OpenSaasConfig = {
88
- db: {
89
- provider: 'sqlite',
90
- url: 'file:./dev.db',
91
- },
92
- mcp: {
93
- enabled: true,
94
- },
95
- lists: {
96
- User: {
97
- fields: {
98
- name: text(),
99
- },
100
- },
101
- },
102
- }
103
-
104
- generateMcp(config, tempDir)
105
-
106
- const toolsPath = path.join(tempDir, '.opensaas', 'mcp', 'tools.json')
107
- expect(fs.existsSync(toolsPath)).toBe(true)
108
-
109
- const tools = JSON.parse(fs.readFileSync(toolsPath, 'utf-8'))
110
- expect(tools).toHaveLength(4) // query, create, update, delete
111
-
112
- const toolNames = tools.map((t: { name: string }) => t.name)
113
- expect(toolNames).toContain('list_user_query')
114
- expect(toolNames).toContain('list_user_create')
115
- expect(toolNames).toContain('list_user_update')
116
- expect(toolNames).toContain('list_user_delete')
117
- })
118
-
119
- it('should respect list-level MCP enabled flag', () => {
120
- const config: OpenSaasConfig = {
121
- db: {
122
- provider: 'sqlite',
123
- url: 'file:./dev.db',
124
- },
125
- mcp: {
126
- enabled: true,
127
- },
128
- lists: {
129
- User: {
130
- fields: {
131
- name: text(),
132
- },
133
- mcp: {
134
- enabled: false,
135
- },
136
- },
137
- Post: {
138
- fields: {
139
- title: text(),
140
- },
141
- },
142
- },
143
- }
144
-
145
- generateMcp(config, tempDir)
146
-
147
- const toolsPath = path.join(tempDir, '.opensaas', 'mcp', 'tools.json')
148
- const tools = JSON.parse(fs.readFileSync(toolsPath, 'utf-8'))
149
-
150
- // Only Post tools should be generated
151
- const userTools = tools.filter((t: { listKey: string }) => t.listKey === 'User')
152
- const postTools = tools.filter((t: { listKey: string }) => t.listKey === 'Post')
153
-
154
- expect(userTools).toHaveLength(0)
155
- expect(postTools).toHaveLength(4)
156
- })
157
-
158
- it('should respect custom tool configuration', () => {
159
- const config: OpenSaasConfig = {
160
- db: {
161
- provider: 'sqlite',
162
- url: 'file:./dev.db',
163
- },
164
- mcp: {
165
- enabled: true,
166
- },
167
- lists: {
168
- User: {
169
- fields: {
170
- name: text(),
171
- },
172
- mcp: {
173
- tools: {
174
- read: true,
175
- create: false,
176
- update: false,
177
- delete: false,
178
- },
179
- },
180
- },
181
- },
182
- }
183
-
184
- generateMcp(config, tempDir)
185
-
186
- const toolsPath = path.join(tempDir, '.opensaas', 'mcp', 'tools.json')
187
- const tools = JSON.parse(fs.readFileSync(toolsPath, 'utf-8'))
188
-
189
- expect(tools).toHaveLength(1)
190
- expect(tools[0].name).toBe('list_user_query')
191
- })
192
-
193
- it('should include custom tools', () => {
194
- const config: OpenSaasConfig = {
195
- db: {
196
- provider: 'sqlite',
197
- url: 'file:./dev.db',
198
- },
199
- mcp: {
200
- enabled: true,
201
- },
202
- lists: {
203
- User: {
204
- fields: {
205
- name: text(),
206
- },
207
- mcp: {
208
- tools: {
209
- read: false,
210
- create: false,
211
- update: false,
212
- delete: false,
213
- },
214
- customTools: [
215
- {
216
- name: 'user_verify_email',
217
- description: 'Verify a user email address',
218
- inputSchema: {},
219
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
220
- handler: async () => ({}) as any,
221
- },
222
- ],
223
- },
224
- },
225
- },
226
- }
227
-
228
- generateMcp(config, tempDir)
229
-
230
- const toolsPath = path.join(tempDir, '.opensaas', 'mcp', 'tools.json')
231
- const tools = JSON.parse(fs.readFileSync(toolsPath, 'utf-8'))
232
-
233
- expect(tools).toHaveLength(1)
234
- expect(tools[0].name).toBe('user_verify_email')
235
- expect(tools[0].operation).toBe('custom')
236
- })
237
-
238
- it('should generate README.md with usage instructions', () => {
239
- const config: OpenSaasConfig = {
240
- db: {
241
- provider: 'sqlite',
242
- url: 'file:./dev.db',
243
- },
244
- mcp: {
245
- enabled: true,
246
- },
247
- lists: {
248
- User: {
249
- fields: {
250
- name: text(),
251
- },
252
- },
253
- },
254
- }
255
-
256
- generateMcp(config, tempDir)
257
-
258
- const readmePath = path.join(tempDir, '.opensaas', 'mcp', 'README.md')
259
- expect(fs.existsSync(readmePath)).toBe(true)
260
-
261
- const readme = fs.readFileSync(readmePath, 'utf-8')
262
- expect(readme).toContain('# MCP Tools Reference')
263
- expect(readme).toContain('Available Tools')
264
- expect(readme).toContain('Usage')
265
- expect(readme).toContain('createMcpHandlers')
266
- expect(readme).toContain('Connecting to Claude Desktop')
267
- })
268
-
269
- it('should list tools in README', () => {
270
- const config: OpenSaasConfig = {
271
- db: {
272
- provider: 'sqlite',
273
- url: 'file:./dev.db',
274
- },
275
- mcp: {
276
- enabled: true,
277
- },
278
- lists: {
279
- User: {
280
- fields: {
281
- name: text(),
282
- },
283
- },
284
- },
285
- }
286
-
287
- generateMcp(config, tempDir)
288
-
289
- const readmePath = path.join(tempDir, '.opensaas', 'mcp', 'README.md')
290
- const readme = fs.readFileSync(readmePath, 'utf-8')
291
-
292
- expect(readme).toContain('list_user_query')
293
- expect(readme).toContain('list_user_create')
294
- expect(readme).toContain('list_user_update')
295
- expect(readme).toContain('list_user_delete')
296
- expect(readme).toContain('4 tool(s) available')
297
- })
298
-
299
- it('should handle multiple lists', () => {
300
- const config: OpenSaasConfig = {
301
- db: {
302
- provider: 'sqlite',
303
- url: 'file:./dev.db',
304
- },
305
- mcp: {
306
- enabled: true,
307
- },
308
- lists: {
309
- User: {
310
- fields: {
311
- name: text(),
312
- },
313
- },
314
- Post: {
315
- fields: {
316
- title: text(),
317
- },
318
- },
319
- },
320
- }
321
-
322
- generateMcp(config, tempDir)
323
-
324
- const toolsPath = path.join(tempDir, '.opensaas', 'mcp', 'tools.json')
325
- const tools = JSON.parse(fs.readFileSync(toolsPath, 'utf-8'))
326
-
327
- expect(tools).toHaveLength(8) // 4 tools per list
328
- })
329
-
330
- it('should respect global defaultTools config', () => {
331
- const config: OpenSaasConfig = {
332
- db: {
333
- provider: 'sqlite',
334
- url: 'file:./dev.db',
335
- },
336
- mcp: {
337
- enabled: true,
338
- defaultTools: {
339
- read: true,
340
- create: true,
341
- update: false,
342
- delete: false,
343
- },
344
- },
345
- lists: {
346
- User: {
347
- fields: {
348
- name: text(),
349
- },
350
- },
351
- },
352
- }
353
-
354
- generateMcp(config, tempDir)
355
-
356
- const toolsPath = path.join(tempDir, '.opensaas', 'mcp', 'tools.json')
357
- const tools = JSON.parse(fs.readFileSync(toolsPath, 'utf-8'))
358
-
359
- expect(tools).toHaveLength(2)
360
- const toolNames = tools.map((t: { name: string }) => t.name)
361
- expect(toolNames).toContain('list_user_query')
362
- expect(toolNames).toContain('list_user_create')
363
- })
364
-
365
- it('should use correct dbKey for tool names', () => {
366
- const config: OpenSaasConfig = {
367
- db: {
368
- provider: 'sqlite',
369
- url: 'file:./dev.db',
370
- },
371
- mcp: {
372
- enabled: true,
373
- },
374
- lists: {
375
- BlogPost: {
376
- fields: {
377
- title: text(),
378
- },
379
- },
380
- },
381
- }
382
-
383
- generateMcp(config, tempDir)
384
-
385
- const toolsPath = path.join(tempDir, '.opensaas', 'mcp', 'tools.json')
386
- const tools = JSON.parse(fs.readFileSync(toolsPath, 'utf-8'))
387
-
388
- const toolNames = tools.map((t: { name: string }) => t.name)
389
- expect(toolNames).toContain('list_blogPost_query')
390
- expect(toolNames).toContain('list_blogPost_create')
391
- })
392
- })
393
- })
@@ -1,221 +0,0 @@
1
- /**
2
- * MCP metadata generation
3
- * Generates reference files for MCP configuration
4
- */
5
-
6
- import * as fs from 'fs'
7
- import * as path from 'path'
8
- import type { OpenSaasConfig } from '@opensaas/stack-core'
9
- import { getDbKey } from '@opensaas/stack-core'
10
-
11
- /**
12
- * Generate MCP metadata if MCP is enabled
13
- */
14
- export function generateMcp(config: OpenSaasConfig, outputPath: string): boolean {
15
- // Skip if MCP is not enabled
16
- if (!config.mcp?.enabled) {
17
- return false
18
- }
19
-
20
- // Ensure output directory exists
21
- const mcpDir = path.join(outputPath, '.opensaas', 'mcp')
22
- if (!fs.existsSync(mcpDir)) {
23
- fs.mkdirSync(mcpDir, { recursive: true })
24
- }
25
-
26
- // Generate tool metadata for reference
27
- const tools: Array<{
28
- name: string
29
- description: string
30
- listKey: string
31
- operation: string
32
- }> = []
33
-
34
- for (const [listKey, listConfig] of Object.entries(config.lists)) {
35
- if (listConfig.mcp?.enabled === false) continue
36
-
37
- const dbKey = getDbKey(listKey)
38
- const defaultTools = config.mcp?.defaultTools || {
39
- read: true,
40
- create: true,
41
- update: true,
42
- delete: true,
43
- }
44
-
45
- const enabledTools = {
46
- read: listConfig.mcp?.tools?.read ?? defaultTools.read ?? true,
47
- create: listConfig.mcp?.tools?.create ?? defaultTools.create ?? true,
48
- update: listConfig.mcp?.tools?.update ?? defaultTools.update ?? true,
49
- delete: listConfig.mcp?.tools?.delete ?? defaultTools.delete ?? true,
50
- }
51
-
52
- if (enabledTools.read) {
53
- tools.push({
54
- name: `list_${dbKey}_query`,
55
- description: `Query ${listKey} records with optional filters`,
56
- listKey,
57
- operation: 'query',
58
- })
59
- }
60
-
61
- if (enabledTools.create) {
62
- tools.push({
63
- name: `list_${dbKey}_create`,
64
- description: `Create a new ${listKey} record`,
65
- listKey,
66
- operation: 'create',
67
- })
68
- }
69
-
70
- if (enabledTools.update) {
71
- tools.push({
72
- name: `list_${dbKey}_update`,
73
- description: `Update an existing ${listKey} record`,
74
- listKey,
75
- operation: 'update',
76
- })
77
- }
78
-
79
- if (enabledTools.delete) {
80
- tools.push({
81
- name: `list_${dbKey}_delete`,
82
- description: `Delete a ${listKey} record`,
83
- listKey,
84
- operation: 'delete',
85
- })
86
- }
87
-
88
- // Custom tools
89
- if (listConfig.mcp?.customTools) {
90
- for (const customTool of listConfig.mcp.customTools) {
91
- tools.push({
92
- name: customTool.name,
93
- description: customTool.description,
94
- listKey,
95
- operation: 'custom',
96
- })
97
- }
98
- }
99
- }
100
-
101
- // Write tools.json for reference
102
- const toolsPath = path.join(mcpDir, 'tools.json')
103
- fs.writeFileSync(toolsPath, JSON.stringify(tools, null, 2), 'utf-8')
104
-
105
- // Write README with usage instructions
106
- const readmePath = path.join(mcpDir, 'README.md')
107
- fs.writeFileSync(
108
- readmePath,
109
- `# MCP Tools Reference
110
-
111
- This directory contains metadata about your MCP configuration.
112
-
113
- ## Available Tools
114
-
115
- ${tools.length} tool(s) available:
116
-
117
- ${tools
118
- .map(
119
- (tool) => `- **${tool.name}** (${tool.operation}): ${tool.description}
120
- - List: ${tool.listKey}`,
121
- )
122
- .join('\n\n')}
123
-
124
- ## Usage
125
-
126
- Create your MCP route handler:
127
-
128
- \`\`\`typescript
129
- // app/api/mcp/[[...transport]]/route.ts
130
- import { createMcpHandlers } from '@opensaas/stack-mcp'
131
- import config from '@/opensaas.config'
132
- import { auth } from '@/lib/auth'
133
- import { getContext } from '@/.opensaas/context'
134
-
135
- const { GET, POST, DELETE } = createMcpHandlers({
136
- config,
137
- auth,
138
- getContext
139
- })
140
-
141
- export { GET, POST, DELETE }
142
- \`\`\`
143
-
144
- ## Connecting to Claude Desktop
145
-
146
- ### Option 1: Remote MCP Server (Recommended for Production)
147
-
148
- For production use with OAuth authentication, add your server via **Claude Desktop > Settings > Connectors**.
149
-
150
- Claude Desktop requires:
151
- 1. Your server must be publicly accessible (use ngrok/cloudflare tunnel for local testing)
152
- 2. OAuth authorization server at \`/.well-known/oauth-authorization-server\`
153
- 3. Dynamic Client Registration (DCR) support - Better Auth MCP plugin provides this
154
-
155
- **Note:** Remote MCP servers with OAuth cannot be configured via \`claude_desktop_config.json\` - they must be added through the Claude Desktop UI.
156
-
157
- ### Option 2: Local Development (No OAuth)
158
-
159
- For local development without OAuth, you can create a proxy MCP server script:
160
-
161
- 1. Create \`mcp-server.js\` in your project root:
162
-
163
- \`\`\`javascript
164
- #!/usr/bin/env node
165
- import { spawn } from 'child_process';
166
-
167
- // Start Next.js dev server in background
168
- const server = spawn('npm', ['run', 'dev'], { stdio: 'inherit' });
169
-
170
- // Wait for server to be ready
171
- setTimeout(() => {
172
- console.log('MCP server ready at http://localhost:3000${config.mcp.basePath || '/api/mcp'}');
173
- }, 3000);
174
-
175
- process.on('SIGINT', () => {
176
- server.kill();
177
- process.exit();
178
- });
179
- \`\`\`
180
-
181
- 2. Add to \`claude_desktop_config.json\`:
182
-
183
- \`\`\`json
184
- {
185
- "mcpServers": {
186
- "my-app": {
187
- "command": "node",
188
- "args": ["mcp-server.js"]
189
- }
190
- }
191
- }
192
- \`\`\`
193
-
194
- ### Option 3: Using ngrok for Local OAuth Testing
195
-
196
- 1. Start your dev server: \`npm run dev\`
197
- 2. Expose with ngrok: \`ngrok http 3000\`
198
- 3. Add to Claude Desktop via **Settings > Connectors** with your ngrok URL
199
- `,
200
- 'utf-8',
201
- )
202
-
203
- return true
204
- }
205
-
206
- /**
207
- * Write MCP metadata to disk
208
- */
209
- export function writeMcp(config: OpenSaasConfig, outputPath: string) {
210
- const generated = generateMcp(config, outputPath)
211
-
212
- if (!generated) {
213
- // Clean up MCP directory if MCP is disabled
214
- const mcpDir = path.join(outputPath, '.opensaas', 'mcp')
215
- if (fs.existsSync(mcpDir)) {
216
- fs.rmSync(mcpDir, { recursive: true, force: true })
217
- }
218
- }
219
-
220
- return generated
221
- }