@nospt/plugin-dev-ai-hub-backend 0.1.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.
Files changed (36) hide show
  1. package/.eslintrc.js +1 -0
  2. package/dev/index.ts +8 -0
  3. package/dist/database/AiAssetStore.cjs.js +224 -0
  4. package/dist/database/AiAssetStore.cjs.js.map +1 -0
  5. package/dist/index.cjs.js +11 -0
  6. package/dist/index.cjs.js.map +1 -0
  7. package/dist/index.d.ts +5 -0
  8. package/dist/plugin.cjs.js +70 -0
  9. package/dist/plugin.cjs.js.map +1 -0
  10. package/dist/router.cjs.js +247 -0
  11. package/dist/router.cjs.js.map +1 -0
  12. package/dist/service/AiAssetSyncService.cjs.js +210 -0
  13. package/dist/service/AiAssetSyncService.cjs.js.map +1 -0
  14. package/dist/service/AssetParser.cjs.js +91 -0
  15. package/dist/service/AssetParser.cjs.js.map +1 -0
  16. package/dist/service/McpServerService.cjs.js +366 -0
  17. package/dist/service/McpServerService.cjs.js.map +1 -0
  18. package/migrations/001_init.js +47 -0
  19. package/migrations/002_install_count.js +17 -0
  20. package/migrations/003_install_paths.js +19 -0
  21. package/migrations/004_label.js +17 -0
  22. package/migrations/005_remove_unused_fields.js +22 -0
  23. package/migrations/006_resources_content.js +16 -0
  24. package/package.json +72 -0
  25. package/package.json-prepack +64 -0
  26. package/src/database/AiAssetStore.test.ts +294 -0
  27. package/src/database/AiAssetStore.ts +256 -0
  28. package/src/index.ts +2 -0
  29. package/src/plugin.ts +84 -0
  30. package/src/router.ts +338 -0
  31. package/src/service/AiAssetSyncService.ts +267 -0
  32. package/src/service/AssetParser.test.ts +261 -0
  33. package/src/service/AssetParser.ts +117 -0
  34. package/src/service/McpServerService.ts +464 -0
  35. package/src/types.ts +31 -0
  36. package/tsconfig.json +8 -0
@@ -0,0 +1,464 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+ import type { AiAssetStore } from '../database/AiAssetStore';
4
+ import type { ProviderConfig } from '../types';
5
+ import type { AssetType } from '@nospt/plugin-dev-ai-hub-common';
6
+ import { getInstallPathsForAsset } from '@nospt/plugin-dev-ai-hub-common';
7
+
8
+ /** Derives a human-readable label from a Git target URL. */
9
+ function providerLabel(target: string): string {
10
+ return target.split('/').pop()?.replace(/\.git$/, '') ?? target;
11
+ }
12
+
13
+ /**
14
+ * Creates an McpServer instance connected to the AiAssetStore.
15
+ *
16
+ * @param store - Asset store
17
+ * @param toolFilter - Captured from `?tool=` at session init; filters assets by tool compatibility
18
+ * @param providerFilter - Captured from `?provider=` at session init; scopes assets to a single provider
19
+ * @param providers - Full provider config list (for `list_providers` and label resolution)
20
+ * @param proactiveEnabled - When false, the `check_for_assets` prompt and `suggest_assets` tool are not registered
21
+ */
22
+ export function createMcpServer(
23
+ store: AiAssetStore,
24
+ toolFilter: string,
25
+ providerFilter: string,
26
+ providers: ProviderConfig[],
27
+ proactiveEnabled = false,
28
+ ): McpServer {
29
+ const server = new McpServer({ name: 'dev-ai-hub', version: '0.1.0' });
30
+
31
+ const toolParam = toolFilter.trim() || undefined;
32
+ const providerParam = providerFilter.trim() || undefined;
33
+
34
+ /** Base filter applied to every list query in this session. */
35
+ const sessionFilter = { tool: toolParam, providerId: providerParam };
36
+
37
+ const resolveProviderLabel = (id: string) =>
38
+ providerLabel(providers.find(p => p.id === id)?.target ?? id);
39
+
40
+ /** Returns the human-readable display name for an asset. */
41
+ const displayName = (a: { name: string; label?: string }) => a.label ?? a.name;
42
+
43
+ /**
44
+ * Resolves an asset by name: tries exact match on name/label first,
45
+ * then label/name contains match, then falls back to first search result.
46
+ */
47
+ const resolveByName = async (name: string) => {
48
+ const { items } = await store.listAssets({ ...sessionFilter, search: name, pageSize: 20 });
49
+ const lower = name.toLowerCase();
50
+ return (
51
+ items.find(a => a.name.toLowerCase() === lower) ??
52
+ items.find(a => (a.label ?? '').toLowerCase() === lower) ??
53
+ items.find(a => a.name.toLowerCase().includes(lower)) ??
54
+ items.find(a => (a.label ?? '').toLowerCase().includes(lower)) ??
55
+ items[0] ??
56
+ null
57
+ );
58
+ };
59
+
60
+ // ── MCP Prompt: check_for_assets ─────────────────────────────────────────
61
+ //
62
+ // Teaches the model to proactively check the hub when it detects project
63
+ // context. Supported by Claude Code (/mcp), and other MCP-compatible tools.
64
+ // Only registered when proactiveEnabled=true (default).
65
+
66
+ if (proactiveEnabled) {
67
+ server.prompt(
68
+ 'check_for_assets',
69
+ 'Check Dev AI Hub for relevant instructions, agents, or skills before starting work on a project.',
70
+ {
71
+ context: z.string().describe(
72
+ 'Describe what you are about to work on (e.g. "Python FastAPI REST API", "React TypeScript frontend")',
73
+ ),
74
+ },
75
+ ({ context }) => ({
76
+ messages: [
77
+ {
78
+ role: 'user' as const,
79
+ content: {
80
+ type: 'text' as const,
81
+ text: [
82
+ `I'm about to work on: ${context}`,
83
+ '',
84
+ 'Before starting, call suggest_assets with this context to find relevant assets from Dev AI Hub.',
85
+ 'If you find relevant instructions, agents, or skills, briefly describe each and offer to install them.',
86
+ 'When the user confirms, call install_asset and then immediately write the "content" field to "recommended_path", creating parent directories if needed.',
87
+ 'After installing, check related_assets in the response and offer those too.',
88
+ ].join('\n'),
89
+ },
90
+ },
91
+ ],
92
+ }),
93
+ );
94
+ }
95
+
96
+ // ── suggest_assets ────────────────────────────────────────────────────────
97
+ // Only registered when proactiveEnabled=true (default).
98
+
99
+ if (proactiveEnabled) {
100
+ server.tool(
101
+ 'suggest_assets',
102
+ [
103
+ 'Proactively suggests relevant assets based on project context (language, framework, task).',
104
+ 'Use this at the start of a session or when the user describes what they are building.',
105
+ 'Prefer this over search_assets for proactive recommendations.',
106
+ ].join(' '),
107
+ {
108
+ context: z.string().describe(
109
+ 'Describe the project or task (e.g. "Python FastAPI REST API", "React TypeScript frontend with testing")',
110
+ ),
111
+ limit: z.number().int().positive().max(10).default(5),
112
+ },
113
+ async ({ context, limit }) => {
114
+ const { items } = await store.listAssets({
115
+ ...sessionFilter,
116
+ search: context,
117
+ pageSize: limit,
118
+ page: 1,
119
+ });
120
+
121
+ return {
122
+ content: [{
123
+ type: 'text' as const,
124
+ text: JSON.stringify({
125
+ context,
126
+ suggestions: items.map(a => ({
127
+ id: a.id,
128
+ name: displayName(a),
129
+ type: a.type,
130
+ description: a.description,
131
+ tags: a.tags,
132
+ installCount: a.installCount,
133
+ provider: { id: a.providerId, label: resolveProviderLabel(a.providerId) },
134
+ install_hint: `install_asset(id: "${a.id}")`,
135
+ })),
136
+ }, null, 2),
137
+ }],
138
+ };
139
+ },
140
+ );
141
+ }
142
+
143
+ // ── search_assets ─────────────────────────────────────────────────────────
144
+
145
+ server.tool(
146
+ 'search_assets',
147
+ [
148
+ 'Search AI assets by text query, type, and tags.',
149
+ 'Use when the user explicitly asks to find or browse assets.',
150
+ 'Follow up with get_asset to preview content, or install_asset to install directly.',
151
+ ].join(' '),
152
+ {
153
+ query: z.string().optional().describe('Full-text search across name, description and content'),
154
+ type: z.enum(['instruction', 'agent', 'skill', 'workflow']).optional().describe('Filter by asset type'),
155
+ tags: z.array(z.string()).optional().describe('Filter by one or more tags'),
156
+ page: z.number().int().positive().default(1),
157
+ pageSize: z.number().int().positive().max(100).default(20),
158
+ },
159
+ async ({ query, type, tags, page, pageSize }) => {
160
+ const { items, totalCount } = await store.listAssets({
161
+ ...sessionFilter,
162
+ search: query,
163
+ type: type as AssetType | undefined,
164
+ tags,
165
+ page,
166
+ pageSize,
167
+ });
168
+
169
+ return {
170
+ content: [{
171
+ type: 'text' as const,
172
+ text: JSON.stringify({
173
+ totalCount,
174
+ page,
175
+ pageSize,
176
+ results: items.map(a => ({
177
+ id: a.id,
178
+ name: displayName(a),
179
+ type: a.type,
180
+ description: a.description,
181
+ tools: a.tools,
182
+ tags: a.tags,
183
+ version: a.version,
184
+ author: a.author,
185
+ installCount: a.installCount,
186
+ provider: { id: a.providerId, label: resolveProviderLabel(a.providerId) },
187
+ })),
188
+ }, null, 2),
189
+ }],
190
+ };
191
+ },
192
+ );
193
+
194
+ // ── list_assets ───────────────────────────────────────────────────────────
195
+
196
+ server.tool(
197
+ 'list_assets',
198
+ [
199
+ 'List all available AI assets, optionally filtered by type.',
200
+ 'Use for browsing or when the user asks what assets are available.',
201
+ 'For context-aware recommendations, prefer suggest_assets instead.',
202
+ ].join(' '),
203
+ {
204
+ type: z.enum(['instruction', 'agent', 'skill', 'workflow']).optional().describe('Filter by asset type'),
205
+ page: z.number().int().positive().default(1),
206
+ pageSize: z.number().int().positive().max(100).default(20),
207
+ },
208
+ async ({ type, page, pageSize }) => {
209
+ const { items, totalCount } = await store.listAssets({
210
+ ...sessionFilter,
211
+ type: type as AssetType | undefined,
212
+ page,
213
+ pageSize,
214
+ });
215
+
216
+ return {
217
+ content: [{
218
+ type: 'text' as const,
219
+ text: JSON.stringify({
220
+ totalCount,
221
+ page,
222
+ pageSize,
223
+ totalPages: Math.ceil(totalCount / pageSize),
224
+ items: items.map(a => ({
225
+ id: a.id,
226
+ name: displayName(a),
227
+ type: a.type,
228
+ description: a.description,
229
+ tags: a.tags,
230
+ version: a.version,
231
+ author: a.author,
232
+ installCount: a.installCount,
233
+ provider: { id: a.providerId, label: resolveProviderLabel(a.providerId) },
234
+ })),
235
+ }, null, 2),
236
+ }],
237
+ };
238
+ },
239
+ );
240
+
241
+ // ── get_asset ─────────────────────────────────────────────────────────────
242
+
243
+ server.tool(
244
+ 'get_asset',
245
+ [
246
+ 'Get full markdown content and metadata of a specific asset by ID or name.',
247
+ 'Use this to preview an asset before installing.',
248
+ 'To install, use install_asset — it fetches content and tracks the install in one step.',
249
+ ].join(' '),
250
+ {
251
+ id: z.string().optional().describe('Exact asset ID'),
252
+ name: z.string().optional().describe('Asset name — case-insensitive, partial match'),
253
+ },
254
+ async ({ id, name }) => {
255
+ let asset = null;
256
+
257
+ if (id) {
258
+ asset = await store.getAsset(id);
259
+ } else if (name) {
260
+ const summary = await resolveByName(name);
261
+ asset = summary ? await store.getAsset(summary.id) : null;
262
+ }
263
+
264
+ if (!asset) {
265
+ return {
266
+ content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Asset not found.' }) }],
267
+ isError: true,
268
+ };
269
+ }
270
+
271
+ const installPaths = getInstallPathsForAsset(asset.type, asset.tools, asset.name, {
272
+ installPath: asset.installPath,
273
+ installPaths: asset.installPaths,
274
+ });
275
+
276
+ return {
277
+ content: [{
278
+ type: 'text' as const,
279
+ text: JSON.stringify({
280
+ id: asset.id,
281
+ name: displayName(asset),
282
+ type: asset.type,
283
+ description: asset.description,
284
+ tools: asset.tools,
285
+ tags: asset.tags,
286
+ version: asset.version,
287
+ author: asset.author,
288
+ metadata: asset.metadata,
289
+ provider: { id: asset.providerId, label: resolveProviderLabel(asset.providerId) },
290
+ installPaths,
291
+ content: asset.content,
292
+ }, null, 2),
293
+ }],
294
+ };
295
+ },
296
+ );
297
+
298
+ // ── get_popular ───────────────────────────────────────────────────────────
299
+
300
+ server.tool(
301
+ 'get_popular',
302
+ 'Returns the most-installed assets in the hub. Use when the user asks what is popular, trending, or most used.',
303
+ {
304
+ limit: z.number().int().positive().max(20).default(5),
305
+ type: z.enum(['instruction', 'agent', 'skill', 'workflow']).optional(),
306
+ },
307
+ async ({ limit, type }) => {
308
+ const { items } = await store.listAssets({
309
+ ...sessionFilter,
310
+ type: type as AssetType | undefined,
311
+ pageSize: 100,
312
+ page: 1,
313
+ });
314
+
315
+ const popular = [...items]
316
+ .sort((a, b) => b.installCount - a.installCount)
317
+ .slice(0, limit)
318
+ .map(a => ({
319
+ id: a.id,
320
+ name: displayName(a),
321
+ type: a.type,
322
+ description: a.description,
323
+ installCount: a.installCount,
324
+ tags: a.tags,
325
+ provider: { id: a.providerId, label: resolveProviderLabel(a.providerId) },
326
+ }));
327
+
328
+ return {
329
+ content: [{ type: 'text' as const, text: JSON.stringify({ popular }, null, 2) }],
330
+ };
331
+ },
332
+ );
333
+
334
+ // ── list_providers ────────────────────────────────────────────────────────
335
+
336
+ server.tool(
337
+ 'list_providers',
338
+ 'Lists all asset repositories configured in Dev AI Hub with sync status and asset counts.',
339
+ {},
340
+ async () => {
341
+ const statuses = await store.getAllSyncStatuses();
342
+ const statusMap = new Map(statuses.map(s => [s.providerId, s]));
343
+
344
+ return {
345
+ content: [{
346
+ type: 'text' as const,
347
+ text: JSON.stringify({
348
+ providers: providers.map(p => {
349
+ const status = statusMap.get(p.id);
350
+ return {
351
+ id: p.id,
352
+ label: providerLabel(p.target),
353
+ type: p.type,
354
+ target: p.target,
355
+ branch: p.branch,
356
+ assetCount: status?.assetCount ?? 0,
357
+ lastSync: status?.lastSync,
358
+ status: status?.status ?? 'idle',
359
+ };
360
+ }),
361
+ }, null, 2),
362
+ }],
363
+ };
364
+ },
365
+ );
366
+
367
+ // ── install_asset ─────────────────────────────────────────────────────────
368
+
369
+ server.tool(
370
+ 'install_asset',
371
+ [
372
+ 'Fetches asset content and the recommended install path for this tool.',
373
+ 'Accepts asset ID or name (partial match supported).',
374
+ 'IMPORTANT: After calling this tool you MUST write the file yourself:',
375
+ '1. Use recommended_path as the destination.',
376
+ '2. Create all parent directories if they do not exist.',
377
+ '3. Write the value of "content" verbatim to that path.',
378
+ 'Calling this tool alone does NOT write any file — you must perform the write.',
379
+ 'After installing, offer the assets listed in related_assets to complement the installation.',
380
+ ].join(' '),
381
+ {
382
+ id: z.string().optional().describe('Exact asset ID (preferred — avoids ambiguity)'),
383
+ name: z.string().optional().describe('Asset name or label — case-insensitive, partial match'),
384
+ },
385
+ async ({ id, name }) => {
386
+ let asset = null;
387
+
388
+ if (id) {
389
+ asset = await store.getAsset(id);
390
+ } else if (name) {
391
+ const summary = await resolveByName(name);
392
+ asset = summary ? await store.getAsset(summary.id) : null;
393
+ }
394
+
395
+ if (!asset) {
396
+ return {
397
+ content: [{ type: 'text' as const, text: JSON.stringify({ error: `Asset not found: ${id ?? name}` }) }],
398
+ isError: true,
399
+ };
400
+ }
401
+
402
+ await store.incrementInstallCount(asset.id);
403
+
404
+ const installPaths = getInstallPathsForAsset(asset.type, asset.tools, asset.name, {
405
+ installPath: asset.installPath,
406
+ installPaths: asset.installPaths,
407
+ });
408
+
409
+ const recommendedPath = toolParam
410
+ ? (installPaths[toolParam] ?? Object.values(installPaths)[0])
411
+ : Object.values(installPaths)[0];
412
+
413
+ // Related: same type with overlapping tags, sorted by popularity
414
+ const { items: candidates } = await store.listAssets({
415
+ ...sessionFilter,
416
+ type: asset.type,
417
+ pageSize: 20,
418
+ page: 1,
419
+ });
420
+
421
+ const related = candidates
422
+ .filter(a => a.id !== asset!.id)
423
+ .filter(a => asset!.tags.length === 0 || a.tags.some(t => asset!.tags.includes(t)))
424
+ .sort((a, b) => b.installCount - a.installCount)
425
+ .slice(0, 3)
426
+ .map(a => ({ id: a.id, name: displayName(a), type: a.type, description: a.description }));
427
+
428
+ const resources = asset.resourcesContent
429
+ ? Object.entries(asset.resourcesContent).map(([filePath, fileContent]) => ({
430
+ path: filePath,
431
+ content: fileContent,
432
+ }))
433
+ : undefined;
434
+
435
+ return {
436
+ content: [{
437
+ type: 'text' as const,
438
+ text: JSON.stringify({
439
+ action_required: [
440
+ 'Write the "content" field to "recommended_path". Create parent directories if needed.',
441
+ resources?.length
442
+ ? 'Also write each file in "resources" to its "path" relative to the skill directory.'
443
+ : null,
444
+ ].filter(Boolean).join(' '),
445
+ installed: {
446
+ id: asset.id,
447
+ name: displayName(asset),
448
+ type: asset.type,
449
+ provider: { id: asset.providerId, label: resolveProviderLabel(asset.providerId) },
450
+ },
451
+ recommended_path: recommendedPath,
452
+ all_install_paths: installPaths,
453
+ path_override_hint: 'Paths can be customised per-tool in the asset YAML: installPaths: { claude-code: ".claude/rules/custom.md" }',
454
+ content: asset.content,
455
+ resources,
456
+ related_assets: related,
457
+ }, null, 2),
458
+ }],
459
+ };
460
+ },
461
+ );
462
+
463
+ return server;
464
+ }
package/src/types.ts ADDED
@@ -0,0 +1,31 @@
1
+ import type { AiAsset, AssetListFilter } from '@nospt/plugin-dev-ai-hub-common';
2
+
3
+ export interface ProviderConfig {
4
+ id: string;
5
+ type: 'github' | 'bitbucket' | 'azure-devops' | 'gitlab' | 'git';
6
+ target: string;
7
+ branch: string;
8
+ schedule: {
9
+ frequency: { minutes?: number; hours?: number };
10
+ timeout: { minutes?: number; hours?: number };
11
+ };
12
+ filters?: {
13
+ tools?: string[];
14
+ types?: string[];
15
+ };
16
+ }
17
+
18
+ export interface AiAssetInput extends Omit<AiAsset, 'id' | 'syncedAt' | 'createdAt' | 'updatedAt' | 'installCount'> {
19
+ id: string;
20
+ }
21
+
22
+ export interface SyncStatus {
23
+ providerId: string;
24
+ lastSync?: string;
25
+ lastCommit?: string;
26
+ status: 'idle' | 'syncing' | 'error';
27
+ error?: string;
28
+ assetCount: number;
29
+ }
30
+
31
+ export { AssetListFilter };
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"]
8
+ }