@ductape/mcp 0.3.2 → 0.3.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.
- package/README.md +6 -0
- package/dist/frontend-analytics-validator.d.ts +28 -0
- package/dist/frontend-analytics-validator.d.ts.map +1 -0
- package/dist/frontend-analytics-validator.js +168 -0
- package/dist/index.js +232 -42
- package/docs/TOOLS.md +13 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -95,6 +95,12 @@ The server exposes runtime, schema, documentation, CLI, discovery, migration, an
|
|
|
95
95
|
- Agents must implement and verify the route; they must not claim remote availability from local registration alone.
|
|
96
96
|
- Agents must first extract native Ductape primitives from migrated code and reserve Functions for irreducible residual domain logic while preserving original transaction boundaries.
|
|
97
97
|
|
|
98
|
+
6. **`ductape_frontend_analytics_validate_project`**:
|
|
99
|
+
- Read-only audit for deployable applications using `@ductape/react`, `@ductape/vue`, or the browser client.
|
|
100
|
+
- Requires an enabled, deferred, or prohibited decision per application in `ductape/analytics/frontend.json`.
|
|
101
|
+
- When enabled, checks analytics usage, authenticated identity lifecycle, router pageviews, named events, client-failure signals, and auto-capture privacy review.
|
|
102
|
+
- Reports actionable findings without rewriting application source.
|
|
103
|
+
|
|
98
104
|
The `ductape_cli` MCP tool also exposes public app discovery:
|
|
99
105
|
`marketplace search <capability>`, `marketplace categories`, and
|
|
100
106
|
`marketplace get <app_tag>`. Inspect the app before generating or executing an action payload.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type AnalyticsDecisionStatus = 'enabled' | 'deferred' | 'prohibited';
|
|
2
|
+
interface AnalyticsDecision {
|
|
3
|
+
status?: AnalyticsDecisionStatus;
|
|
4
|
+
rationale?: string;
|
|
5
|
+
autoCapture?: 'disabled' | 'reviewed';
|
|
6
|
+
privacyReviewed?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface FrontendAnalyticsValidationResult {
|
|
9
|
+
valid: boolean;
|
|
10
|
+
decisionFile: string;
|
|
11
|
+
summary: {
|
|
12
|
+
applications: number;
|
|
13
|
+
errors: number;
|
|
14
|
+
warnings: number;
|
|
15
|
+
};
|
|
16
|
+
applications: Array<{
|
|
17
|
+
path: string;
|
|
18
|
+
framework: 'react' | 'vue' | 'client';
|
|
19
|
+
decision?: AnalyticsDecision;
|
|
20
|
+
signals: Record<string, boolean>;
|
|
21
|
+
errors: string[];
|
|
22
|
+
warnings: string[];
|
|
23
|
+
}>;
|
|
24
|
+
remediation: string[];
|
|
25
|
+
}
|
|
26
|
+
export declare function validateFrontendAnalyticsProject(projectDir: string): FrontendAnalyticsValidationResult;
|
|
27
|
+
export {};
|
|
28
|
+
//# sourceMappingURL=frontend-analytics-validator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"frontend-analytics-validator.d.ts","sourceRoot":"","sources":["../src/frontend-analytics-validator.ts"],"names":[],"mappings":"AASA,MAAM,MAAM,uBAAuB,GAAG,SAAS,GAAG,UAAU,GAAG,YAAY,CAAC;AAE5E,UAAU,iBAAiB;IACzB,MAAM,CAAC,EAAE,uBAAuB,CAAC;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,UAAU,GAAG,UAAU,CAAC;IACtC,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AAMD,MAAM,WAAW,iCAAiC;IAChD,KAAK,EAAE,OAAO,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE;QAAE,YAAY,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IACpE,YAAY,EAAE,KAAK,CAAC;QAClB,IAAI,EAAE,MAAM,CAAC;QACb,SAAS,EAAE,OAAO,GAAG,KAAK,GAAG,QAAQ,CAAC;QACtC,QAAQ,CAAC,EAAE,iBAAiB,CAAC;QAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QACjC,MAAM,EAAE,MAAM,EAAE,CAAC;QACjB,QAAQ,EAAE,MAAM,EAAE,CAAC;KACpB,CAAC,CAAC;IACH,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB;AA0DD,wBAAgB,gCAAgC,CAC9C,UAAU,EAAE,MAAM,GACjB,iCAAiC,CAkGnC"}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
4
|
+
'.git', '.next', '.nuxt', '.output', 'build', 'coverage', 'dist', 'node_modules',
|
|
5
|
+
]);
|
|
6
|
+
const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.vue']);
|
|
7
|
+
const MAX_SOURCE_BYTES = 1_000_000;
|
|
8
|
+
function walk(root, visit) {
|
|
9
|
+
if (!fs.existsSync(root))
|
|
10
|
+
return;
|
|
11
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
12
|
+
if (entry.isSymbolicLink())
|
|
13
|
+
continue;
|
|
14
|
+
const absolute = path.join(root, entry.name);
|
|
15
|
+
if (entry.isDirectory()) {
|
|
16
|
+
if (!IGNORED_DIRECTORIES.has(entry.name))
|
|
17
|
+
walk(absolute, visit);
|
|
18
|
+
}
|
|
19
|
+
else if (entry.isFile()) {
|
|
20
|
+
visit(absolute);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function readJson(file) {
|
|
25
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
26
|
+
}
|
|
27
|
+
function packageFramework(manifest) {
|
|
28
|
+
const runtimeDependencies = manifest.dependencies ?? {};
|
|
29
|
+
const dependencies = {
|
|
30
|
+
...runtimeDependencies,
|
|
31
|
+
...(manifest.devDependencies ?? {}),
|
|
32
|
+
};
|
|
33
|
+
const scripts = Object.values(manifest.scripts ?? {}).join(' ');
|
|
34
|
+
const isApplication = Boolean(runtimeDependencies['react-dom'] ||
|
|
35
|
+
runtimeDependencies.vue ||
|
|
36
|
+
runtimeDependencies.svelte ||
|
|
37
|
+
runtimeDependencies['@angular/core'] ||
|
|
38
|
+
/(?:^|\s)(?:vite|next|nuxt|react-scripts)(?:\s|$)/.test(scripts) ||
|
|
39
|
+
manifest.browser);
|
|
40
|
+
if (!isApplication)
|
|
41
|
+
return undefined;
|
|
42
|
+
if (dependencies['@ductape/react'])
|
|
43
|
+
return 'react';
|
|
44
|
+
if (dependencies['@ductape/vue'])
|
|
45
|
+
return 'vue';
|
|
46
|
+
if (dependencies['@ductape/client'] && (dependencies.react || dependencies.vue))
|
|
47
|
+
return 'client';
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
function scanSource(applicationRoot) {
|
|
51
|
+
const chunks = [];
|
|
52
|
+
walk(applicationRoot, (file) => {
|
|
53
|
+
if (!SOURCE_EXTENSIONS.has(path.extname(file)))
|
|
54
|
+
return;
|
|
55
|
+
const stats = fs.statSync(file);
|
|
56
|
+
if (stats.size > MAX_SOURCE_BYTES)
|
|
57
|
+
return;
|
|
58
|
+
chunks.push(fs.readFileSync(file, 'utf8'));
|
|
59
|
+
});
|
|
60
|
+
return chunks.join('\n');
|
|
61
|
+
}
|
|
62
|
+
function normalizedRelative(root, target) {
|
|
63
|
+
return path.relative(root, target).split(path.sep).join('/') || '.';
|
|
64
|
+
}
|
|
65
|
+
export function validateFrontendAnalyticsProject(projectDir) {
|
|
66
|
+
const root = path.resolve(projectDir);
|
|
67
|
+
if (!path.isAbsolute(projectDir) || !fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
68
|
+
throw new Error('project_dir must be an existing absolute project directory.');
|
|
69
|
+
}
|
|
70
|
+
const decisionFile = path.join(root, 'ductape', 'analytics', 'frontend.json');
|
|
71
|
+
let decisions = {};
|
|
72
|
+
let decisionError;
|
|
73
|
+
if (fs.existsSync(decisionFile)) {
|
|
74
|
+
try {
|
|
75
|
+
const parsed = readJson(decisionFile);
|
|
76
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
77
|
+
throw new Error('top level must be an object');
|
|
78
|
+
}
|
|
79
|
+
decisions = parsed;
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
decisionError = `Invalid decision file: ${error instanceof Error ? error.message : String(error)}`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const packageFiles = [];
|
|
86
|
+
walk(root, (file) => {
|
|
87
|
+
if (path.basename(file) === 'package.json')
|
|
88
|
+
packageFiles.push(file);
|
|
89
|
+
});
|
|
90
|
+
const applications = packageFiles.flatMap((manifestFile) => {
|
|
91
|
+
let manifest;
|
|
92
|
+
try {
|
|
93
|
+
manifest = readJson(manifestFile);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
const framework = packageFramework(manifest);
|
|
99
|
+
if (!framework)
|
|
100
|
+
return [];
|
|
101
|
+
const applicationRoot = path.dirname(manifestFile);
|
|
102
|
+
const applicationPath = normalizedRelative(root, applicationRoot);
|
|
103
|
+
const source = scanSource(applicationRoot);
|
|
104
|
+
const decision = decisions.applications?.[applicationPath];
|
|
105
|
+
const signals = {
|
|
106
|
+
provider: /DuctapeProvider/.test(source),
|
|
107
|
+
authenticatedSessions: /useSession|\.sessions\.|sessionToken|refreshToken/.test(source),
|
|
108
|
+
analytics: /useAnalytics|\.analytics\./.test(source),
|
|
109
|
+
identify: /(?:analytics\.)?identify\s*\(/.test(source),
|
|
110
|
+
clearSession: /(?:analytics\.)?clearSession\s*\(/.test(source),
|
|
111
|
+
pageview: /(?:analytics\.)?pageview\s*\(/.test(source),
|
|
112
|
+
namedEvents: /(?:analytics\.)?track\s*\(/.test(source),
|
|
113
|
+
autoCapture: /enableAutoCapture\s*\(/.test(source),
|
|
114
|
+
clientFailure: /client[_\-. ]error|frontend[_\-. ]error|error_boundary|unhandledrejection/i.test(source),
|
|
115
|
+
};
|
|
116
|
+
const errors = [];
|
|
117
|
+
const warnings = [];
|
|
118
|
+
if (decisionError)
|
|
119
|
+
errors.push(decisionError);
|
|
120
|
+
if (!decision) {
|
|
121
|
+
errors.push(`Record this application in ${normalizedRelative(root, decisionFile)}.`);
|
|
122
|
+
}
|
|
123
|
+
else if (!['enabled', 'deferred', 'prohibited'].includes(String(decision.status))) {
|
|
124
|
+
errors.push('Decision status must be enabled, deferred, or prohibited.');
|
|
125
|
+
}
|
|
126
|
+
else if (decision.status !== 'enabled' && !decision.rationale?.trim()) {
|
|
127
|
+
errors.push(`${decision.status} analytics requires a non-empty rationale.`);
|
|
128
|
+
}
|
|
129
|
+
else if (decision.status === 'enabled') {
|
|
130
|
+
if (!decision.privacyReviewed)
|
|
131
|
+
errors.push('Enabled analytics requires privacyReviewed: true.');
|
|
132
|
+
if (!signals.analytics)
|
|
133
|
+
errors.push('No Ductape analytics API usage was found.');
|
|
134
|
+
if (!signals.pageview)
|
|
135
|
+
errors.push('No initial/router pageview lifecycle was found.');
|
|
136
|
+
if (!signals.namedEvents)
|
|
137
|
+
errors.push('No reviewed named product event was found.');
|
|
138
|
+
if (signals.authenticatedSessions && !signals.identify) {
|
|
139
|
+
errors.push('Authenticated sessions exist but identify(fullSessionToken) was not found.');
|
|
140
|
+
}
|
|
141
|
+
if (signals.authenticatedSessions && !signals.clearSession) {
|
|
142
|
+
errors.push('Authenticated sessions exist but clearSession() was not found.');
|
|
143
|
+
}
|
|
144
|
+
if (signals.autoCapture && decision.autoCapture !== 'reviewed') {
|
|
145
|
+
errors.push('Auto-capture is enabled without a recorded reviewed decision.');
|
|
146
|
+
}
|
|
147
|
+
if (!signals.clientFailure) {
|
|
148
|
+
warnings.push('No sanitized client-failure analytics signal was detected; review error boundaries and unhandled failures.');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return [{ path: applicationPath, framework, decision, signals, errors, warnings }];
|
|
152
|
+
});
|
|
153
|
+
const errors = applications.reduce((count, app) => count + app.errors.length, 0);
|
|
154
|
+
const warnings = applications.reduce((count, app) => count + app.warnings.length, 0);
|
|
155
|
+
return {
|
|
156
|
+
valid: errors === 0,
|
|
157
|
+
decisionFile,
|
|
158
|
+
summary: { applications: applications.length, errors, warnings },
|
|
159
|
+
applications,
|
|
160
|
+
remediation: [
|
|
161
|
+
'Read ductape_docs({ topic: "frontend-analytics" }) before changing application code.',
|
|
162
|
+
'Create ductape/analytics/frontend.json with one applications entry per reported relative path.',
|
|
163
|
+
'Choose enabled, deferred, or prohibited explicitly; never treat silent omission as completion.',
|
|
164
|
+
'When enabled, keep auto-capture off until consent, masking, selectors, and sensitive states are reviewed.',
|
|
165
|
+
'This validator is read-only and must not be used as an automatic codemod.',
|
|
166
|
+
],
|
|
167
|
+
};
|
|
168
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { normalizeLiveActionContract } from './action-contract.js';
|
|
|
19
19
|
import { runtimeInputRecovery } from './runtime-recovery.js';
|
|
20
20
|
import { executeViaProxy, generateExecutablePayload, getAssetSchemas, } from './proxy-client.js';
|
|
21
21
|
import { EVENTS_DELIVERY_SEMANTICS, EVENTS_IMPLEMENTATION_WARNING, eventsCapabilityOverview, inspectEventsComponent, searchEventsCapabilities, } from './events-capabilities.js';
|
|
22
|
+
import { validateFrontendAnalyticsProject } from './frontend-analytics-validator.js';
|
|
22
23
|
const MODULES = [
|
|
23
24
|
'product', 'app', 'databases', 'graph', 'webhooks', 'notifications',
|
|
24
25
|
'events', 'messageBrokers', 'storage', 'vector', 'caches', 'sessions', 'quotas',
|
|
@@ -365,6 +366,9 @@ SETUP — register once in AppModule:
|
|
|
365
366
|
env: process.env.NODE_ENV === 'production' ? 'prd' : 'snd',
|
|
366
367
|
redisUrl: process.env.DUCTAPE_REDIS_URL, // required — no dispatch() works without this
|
|
367
368
|
runtimeSync: { intervalMs: 30_000, jitter: 0.2 },
|
|
369
|
+
// Resolve only the original opaque session retained by the authentication guard.
|
|
370
|
+
sessionResolver: (context) =>
|
|
371
|
+
context.switchToHttp().getRequest().actor?.ductapeSession,
|
|
368
372
|
}),
|
|
369
373
|
],
|
|
370
374
|
})
|
|
@@ -380,9 +384,22 @@ SETUP — register once in AppModule:
|
|
|
380
384
|
env: cfg.get('DUCTAPE_ENV'),
|
|
381
385
|
redisUrl: cfg.get('DUCTAPE_REDIS_URL'), // required — no dispatch() works without this
|
|
382
386
|
runtimeSync: { intervalMs: 30_000, jitter: 0.2 },
|
|
387
|
+
sessionResolver: (context) =>
|
|
388
|
+
context.switchToHttp().getRequest().actor?.ductapeSession,
|
|
383
389
|
}),
|
|
384
390
|
})
|
|
385
391
|
|
|
392
|
+
SESSION-AWARE NESTJS EXECUTION:
|
|
393
|
+
Configure sessionResolver once. It runs after guards and stores the returned opaque token in
|
|
394
|
+
request-scoped context. @Feature.Execute, @Feature.Dispatch, @Api, @ApiDispatch, Events,
|
|
395
|
+
notifications, jobs, agents, and session-capable injected resource handles inherit it without
|
|
396
|
+
adding it to business input. Do not return a session from a decorated method or put it in DTOs.
|
|
397
|
+
The resolver must return the exact original "session-tag:jwt", not decoded claims or a bearer
|
|
398
|
+
header. For intentional webhook, scheduled, or broker system work, set
|
|
399
|
+
sessionContext: "system" on session-capable decorators; this suppresses request inheritance.
|
|
400
|
+
Check the installed @ductape/nestjs types before generating this configuration: 0.1.12 does not
|
|
401
|
+
yet contain sessionResolver/sessionContext and must be upgraded to the next published release.
|
|
402
|
+
|
|
386
403
|
Environment variable (add to .env and deployment secrets):
|
|
387
404
|
DUCTAPE_REDIS_URL=redis://localhost:6379 # local dev
|
|
388
405
|
DUCTAPE_REDIS_URL=rediss://:<password>@host:6380 # managed Redis (TLS)
|
|
@@ -857,7 +874,13 @@ ALL params are passed as a JSON array in positional order matching the SDK signa
|
|
|
857
874
|
graph.create [{ product, tag, name, description?, type: "neo4j"|"nebula"|"arangodb", envs: [{slug, connection_url, username?, password?}] }]
|
|
858
875
|
graph.fetch [product_tag, graph_tag]
|
|
859
876
|
graph.list [product_tag?]
|
|
860
|
-
graph.update [product_tag, graph_tag, data: { name?: string, description?: string, type?: "neo4j"|"neptune"|"arangodb"|"memgraph", envs?: [{ slug: string, connection_url
|
|
877
|
+
graph.update [product_tag, graph_tag, data: { name?: string, description?: string, type?: "neo4j"|"neptune"|"arangodb"|"memgraph", envs?: [{ slug: string, connection_url?: string, username?: string, password?: string, database?: string, graphName?: string, region?: string }] }]
|
|
878
|
+
Each env entry is a PARTIAL patch merged with that env's existing persisted config — only
|
|
879
|
+
include the fields you're actually changing (e.g. just { slug, password } to rotate a
|
|
880
|
+
password). connection_url is required only when adding a brand-new env slug; omitting it on
|
|
881
|
+
an existing slug leaves the current value untouched. Field name is "password", not
|
|
882
|
+
"masterPassword" — that's a different field used only by cloud resources import-persist-all
|
|
883
|
+
(see the Neo4j Aura import flow section below), not by graph.create/graph.update.
|
|
861
884
|
graph.delete [graph_tag, product_tag?]
|
|
862
885
|
graph.connect [{ product, env, graph }]
|
|
863
886
|
graph.testConnection [config]
|
|
@@ -1311,7 +1334,7 @@ function buildSdkInvocationArgs(payload) {
|
|
|
1311
1334
|
function operationAcceptsSession(operationFamily, method) {
|
|
1312
1335
|
const family = operationFamily.toLowerCase();
|
|
1313
1336
|
const sessionFamilies = new Set([
|
|
1314
|
-
'action', 'features', 'feature', 'database', 'graph', 'vector', 'storage',
|
|
1337
|
+
'action', 'api', 'app', 'apps', 'features', 'feature', 'database', 'graph', 'vector', 'storage',
|
|
1315
1338
|
'notification', 'messaging', 'broker', 'events', 'event', 'quota', 'fallback',
|
|
1316
1339
|
]);
|
|
1317
1340
|
if (!sessionFamilies.has(family))
|
|
@@ -1444,7 +1467,7 @@ function buildDatabaseActionFeatureExample(targets, contract) {
|
|
|
1444
1467
|
let authState = 'unknown';
|
|
1445
1468
|
let workspaceSynced = false;
|
|
1446
1469
|
const ADMIN_SUBCOMMANDS = [
|
|
1447
|
-
'login', 'logout', 'whoami',
|
|
1470
|
+
'login', 'logout', 'whoami', 'doctor',
|
|
1448
1471
|
'profiles',
|
|
1449
1472
|
'workspaces',
|
|
1450
1473
|
'link', 'unlink', 'init',
|
|
@@ -1469,7 +1492,7 @@ const ADMIN_SUBCOMMANDS = [
|
|
|
1469
1492
|
'migration-products',
|
|
1470
1493
|
'migration-secrets',
|
|
1471
1494
|
];
|
|
1472
|
-
function checkCli() {
|
|
1495
|
+
function checkCli(projectDir) {
|
|
1473
1496
|
try {
|
|
1474
1497
|
const out = execFileSync('ductape', ['--version'], {
|
|
1475
1498
|
shell: false,
|
|
@@ -1477,7 +1500,7 @@ function checkCli() {
|
|
|
1477
1500
|
timeout: 15000,
|
|
1478
1501
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1479
1502
|
env: cliEnvironment(),
|
|
1480
|
-
cwd: cliCwd(),
|
|
1503
|
+
cwd: cliCwd(projectDir),
|
|
1481
1504
|
}).trim();
|
|
1482
1505
|
return { available: true, version: out || 'unknown' };
|
|
1483
1506
|
}
|
|
@@ -1489,7 +1512,7 @@ function checkCli() {
|
|
|
1489
1512
|
return { available: !commandMissing };
|
|
1490
1513
|
}
|
|
1491
1514
|
}
|
|
1492
|
-
function checkLoginState() {
|
|
1515
|
+
function checkLoginState(projectDir) {
|
|
1493
1516
|
try {
|
|
1494
1517
|
// `ductape whoami` only reports whether a local credentials file exists. It does not validate
|
|
1495
1518
|
// the stored token, so an expired token would be cached as authenticated and fail later with 401.
|
|
@@ -1500,7 +1523,7 @@ function checkLoginState() {
|
|
|
1500
1523
|
timeout: 10000,
|
|
1501
1524
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1502
1525
|
env: cliEnvironment(),
|
|
1503
|
-
cwd: cliCwd(),
|
|
1526
|
+
cwd: cliCwd(projectDir),
|
|
1504
1527
|
});
|
|
1505
1528
|
authState = 'ok';
|
|
1506
1529
|
return 'ok';
|
|
@@ -1510,7 +1533,7 @@ function checkLoginState() {
|
|
|
1510
1533
|
return 'none';
|
|
1511
1534
|
}
|
|
1512
1535
|
}
|
|
1513
|
-
function syncWorkspace() {
|
|
1536
|
+
function syncWorkspace(projectDir) {
|
|
1514
1537
|
const target = process.env.DUCTAPE_WORKSPACE;
|
|
1515
1538
|
workspaceSynced = true; // mark done regardless so we don't retry on every call
|
|
1516
1539
|
if (!target)
|
|
@@ -1522,14 +1545,14 @@ function syncWorkspace() {
|
|
|
1522
1545
|
timeout: 10000,
|
|
1523
1546
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1524
1547
|
env: cliEnvironment(),
|
|
1525
|
-
cwd: cliCwd(),
|
|
1548
|
+
cwd: cliCwd(projectDir),
|
|
1526
1549
|
});
|
|
1527
1550
|
}
|
|
1528
1551
|
catch {
|
|
1529
1552
|
// best-effort; if it fails the user will see workspace-mismatch errors on subsequent commands
|
|
1530
1553
|
}
|
|
1531
1554
|
}
|
|
1532
|
-
function runCli(command) {
|
|
1555
|
+
function runCli(command, projectDir) {
|
|
1533
1556
|
let argv;
|
|
1534
1557
|
try {
|
|
1535
1558
|
argv = parseCliCommand(command);
|
|
@@ -1558,7 +1581,7 @@ function runCli(command) {
|
|
|
1558
1581
|
timeout: 90000,
|
|
1559
1582
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1560
1583
|
env: cliEnvironment(),
|
|
1561
|
-
cwd: cliCwd(),
|
|
1584
|
+
cwd: cliCwd(projectDir),
|
|
1562
1585
|
});
|
|
1563
1586
|
return { success: true, output: output.trim() };
|
|
1564
1587
|
}
|
|
@@ -1567,7 +1590,7 @@ function runCli(command) {
|
|
|
1567
1590
|
if (/\bHTTP 401\b|unauthori[sz]ed|invalid token|token expired/i.test(msg)) {
|
|
1568
1591
|
// Distinguish a genuinely expired login from a command-specific endpoint/auth bug.
|
|
1569
1592
|
// A valid workspace read proves the CLI session and selected workspace are authenticated.
|
|
1570
|
-
if (checkLoginState() === 'ok') {
|
|
1593
|
+
if (checkLoginState(projectDir) === 'ok') {
|
|
1571
1594
|
return {
|
|
1572
1595
|
success: false,
|
|
1573
1596
|
output: [
|
|
@@ -1635,8 +1658,14 @@ function cliEnvironment() {
|
|
|
1635
1658
|
* cwd is unrelated to the project). Set DUCTAPE_PROJECT_DIR explicitly in the server's env
|
|
1636
1659
|
* (e.g. in .mcp.json) to pin it; falls back to this process's own cwd otherwise.
|
|
1637
1660
|
*/
|
|
1638
|
-
|
|
1639
|
-
|
|
1661
|
+
// `process.cwd()` here is the long-running MCP server process's own working directory — fixed for
|
|
1662
|
+
// its whole lifetime by whatever launched it, not the project the calling agent is currently
|
|
1663
|
+
// working in. A static DUCTAPE_PROJECT_DIR env var has the same problem: it can't track an agent
|
|
1664
|
+
// session that moves between projects. `override` lets a per-call `project_dir` argument (see
|
|
1665
|
+
// cliInputSchema, eventsProjectValidationInputSchema, eventsTopicSetupInputSchema) take precedence
|
|
1666
|
+
// over both, so a single long-lived MCP server can be pointed at the right project per call.
|
|
1667
|
+
function cliCwd(override) {
|
|
1668
|
+
return override || process.env.DUCTAPE_PROJECT_DIR || process.cwd();
|
|
1640
1669
|
}
|
|
1641
1670
|
function shellArgument(value) {
|
|
1642
1671
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
@@ -1676,9 +1705,23 @@ const eventsTopicSetupInputSchema = z.object({
|
|
|
1676
1705
|
sample: z.record(z.unknown()).optional(),
|
|
1677
1706
|
idempotent: z.boolean().optional(),
|
|
1678
1707
|
queueUrls: z.array(z.object({ env_slug: z.string().min(1), url: z.string().url() }).strict()).optional(),
|
|
1708
|
+
project_dir: z.string().optional().describe('Absolute path to the project this topic asset belongs to. Without this, the returned path ' +
|
|
1709
|
+
'resolves against the MCP server\'s own working directory or DUCTAPE_PROJECT_DIR, which does ' +
|
|
1710
|
+
'not track which project the current session is working in — pass it whenever working in a ' +
|
|
1711
|
+
'project other than wherever the MCP server happened to start.'),
|
|
1679
1712
|
});
|
|
1680
1713
|
const eventsProjectValidationInputSchema = z.object({
|
|
1681
|
-
dir: z.string().default('ductape/events').describe('Must be ductape/events relative to
|
|
1714
|
+
dir: z.string().default('ductape/events').describe('Must be ductape/events relative to the resolved project directory.'),
|
|
1715
|
+
project_dir: z.string().optional().describe('Absolute path to the project to validate. Without this, resolution falls back to the MCP ' +
|
|
1716
|
+
'server\'s own working directory or DUCTAPE_PROJECT_DIR, which does not track which project ' +
|
|
1717
|
+
'the current session is working in — pass it whenever working in a project other than ' +
|
|
1718
|
+
'wherever the MCP server happened to start.'),
|
|
1719
|
+
});
|
|
1720
|
+
const featuresProjectValidationInputSchema = z.object({
|
|
1721
|
+
project_dir: z.string().describe('Absolute path to the linked Ductape project containing ductape/features/. The validator is read-only.'),
|
|
1722
|
+
});
|
|
1723
|
+
const frontendAnalyticsProjectValidationInputSchema = z.object({
|
|
1724
|
+
project_dir: z.string().describe('Absolute path to a frontend project or monorepo. The validator is read-only and never modifies application code.'),
|
|
1682
1725
|
});
|
|
1683
1726
|
const readOnlyLocalAnnotations = {
|
|
1684
1727
|
readOnlyHint: true,
|
|
@@ -1914,6 +1957,16 @@ SECURITY
|
|
|
1914
1957
|
$Secret{tag}; manifests never embed credentials. Keep snd/stg/prd values separate.
|
|
1915
1958
|
Use ductape_cli("secrets-import-env --env-file <local-file> --source-key <ENV_KEY> --key <secret-tag> --env <slug> --json").
|
|
1916
1959
|
The command reads the value locally and redacts it from output; never place the value in an MCP argument.
|
|
1960
|
+
Before running that import for any discovered .env-backed secret candidate, ask the user
|
|
1961
|
+
explicitly whether to create a Ductape Secret from it — never decide silently either way (not
|
|
1962
|
+
"migrate everything found" and not "leave .env alone"). Once a value is migrated, update the
|
|
1963
|
+
consuming code to read it through Ductape rather than the environment: NestJS code uses the
|
|
1964
|
+
@Secret() decorator from @ductape/nestjs (see ductape_docs({ topic: "secrets" }) for the DI
|
|
1965
|
+
pattern); every other runtime calls secrets.fetch(key) (ductape_execute("secrets.fetch", [key]))
|
|
1966
|
+
instead of process.env / os.Getenv / System.getenv / IConfiguration for that value. Only remove
|
|
1967
|
+
the .env entry after the new code path is verified working, and never do this for bootstrap
|
|
1968
|
+
config Ductape itself needs to start (DUCTAPE_ACCESS_KEY, DUCTAPE_REDIS_URL, NODE_ENV) — those
|
|
1969
|
+
necessarily stay in the environment.
|
|
1917
1970
|
secret_references are names-only navigation evidence from Docker Compose, Kubernetes secretKeyRef,
|
|
1918
1971
|
GitHub/GitLab/Jenkins, Spring, .NET configuration, Terraform, and AWS/GCP/Azure secret managers.
|
|
1919
1972
|
Their value is always [NOT_READ]. Inspect context before deciding whether a reference is sensitive,
|
|
@@ -2022,7 +2075,9 @@ COMPONENT DECISIONS
|
|
|
2022
2075
|
represents a reusable product capability.
|
|
2023
2076
|
|
|
2024
2077
|
LANGUAGE RUNTIME SHAPES
|
|
2025
|
-
TypeScript: @ductape/sdk; NestJS uses @ductape/nestjs, @Events.Consumer,
|
|
2078
|
+
TypeScript: @ductape/sdk; NestJS uses @ductape/nestjs, @Events.Consumer, request-scoped context,
|
|
2079
|
+
and @Secret() (DuctapeSecretsModule.register({ keys })) for any secret value instead of .env/
|
|
2080
|
+
ConfigService — see ductape_docs({ topic: "secrets" }).
|
|
2026
2081
|
Go: explicit services, context.Context, cancellation, typed errors, and owned worker shutdown.
|
|
2027
2082
|
Java: DI/Spring integration where present, executor ownership, CompletableFuture boundaries.
|
|
2028
2083
|
.NET: DI, hosted services, async/await, CancellationToken, configuration binding.
|
|
@@ -2293,13 +2348,13 @@ PARITY CLAIMS
|
|
|
2293
2348
|
DUCTAPE FRONTEND SDK GUIDE
|
|
2294
2349
|
|
|
2295
2350
|
Choose one integration package:
|
|
2296
|
-
React 17+ — npm install @ductape/react
|
|
2351
|
+
React 17+ — npm install @ductape/react@latest
|
|
2297
2352
|
Provider and hooks built on @ductape/client.
|
|
2298
2353
|
Continue with ductape_docs({ topic: "react" }).
|
|
2299
|
-
Vue 3+ — npm install @ductape/vue
|
|
2354
|
+
Vue 3+ — npm install @ductape/vue@latest
|
|
2300
2355
|
Plugin and composables built on @ductape/client.
|
|
2301
2356
|
Continue with ductape_docs({ topic: "vue" }).
|
|
2302
|
-
Other UI — npm install @ductape/client
|
|
2357
|
+
Other UI — npm install @ductape/client@latest
|
|
2303
2358
|
frameworks Use directly with Svelte, Angular, vanilla JavaScript, or a custom adapter.
|
|
2304
2359
|
Continue with ductape_docs({ topic: "client" }).
|
|
2305
2360
|
|
|
@@ -2324,8 +2379,14 @@ SHARED APPLICATION LIFECYCLE
|
|
|
2324
2379
|
|
|
2325
2380
|
PRODUCT ANALYTICS
|
|
2326
2381
|
Frontend product analytics complements backend session propagation; neither replaces the other.
|
|
2327
|
-
|
|
2328
|
-
|
|
2382
|
+
This is a required design decision for every Ductape frontend, not an optional afterthought.
|
|
2383
|
+
Always continue with ductape_docs({ topic: "frontend-analytics" }), then run
|
|
2384
|
+
ductape_frontend_analytics_validate_project({ project_dir: "<absolute-project-root>" }). Record
|
|
2385
|
+
one explicit enabled/deferred/prohibited choice per detected application in
|
|
2386
|
+
ductape/analytics/frontend.json. Silent omission is a failed frontend build/audit outcome.
|
|
2387
|
+
When enabled, wire identity lifecycle, router pageviews, reviewed named events, sanitized client
|
|
2388
|
+
failures, privacy masking, hidden-state safety, and trace correlation. Keep auto-capture off until
|
|
2389
|
+
consent, selectors, masking, and sensitive states have been reviewed.
|
|
2329
2390
|
|
|
2330
2391
|
AUTHENTICATION AND SECURITY
|
|
2331
2392
|
- Use publishableKey in browser applications. Never ship workspace private keys or privileged
|
|
@@ -2904,7 +2965,7 @@ Import an existing resource and register it on the product:
|
|
|
2904
2965
|
Use import-persist-all for multi-env products (required):
|
|
2905
2966
|
ductape_cli("cloud resources import-persist-all -f all-envs.json --json")
|
|
2906
2967
|
File is a JSON ARRAY — one entry per env, same product + component tag across all entries.
|
|
2907
|
-
Each entry: { cloud, service, type, product, component, env, resource, region?, dbName?, masterPassword? }
|
|
2968
|
+
Each entry: { cloud, service, type, product, component, env, resource, region?, dbName?, masterPassword?, username? }
|
|
2908
2969
|
Supported service identifiers: s3, gcs, blob, rds, postgresql, cloudsql, sqs, pubsub,
|
|
2909
2970
|
servicebus, neptune, cosmos-gremlin, opensearch, azure-search, atlas-cluster, aura-instance,
|
|
2910
2971
|
vertex-vector-search, spanner-graph, dynamodb, keyspaces, mysql
|
|
@@ -2917,9 +2978,17 @@ Import an existing resource and register it on the product:
|
|
|
2917
2978
|
without masterPassword will fail with a clear error at import time; skipping that check and
|
|
2918
2979
|
importing anyway is not possible — always ask the user for the instance's password before
|
|
2919
2980
|
calling import-persist-all for an aura-instance entry, the same as you would ask for an RDS
|
|
2920
|
-
master password.
|
|
2921
|
-
|
|
2922
|
-
|
|
2981
|
+
master password. Atlas (atlas-cluster) does NOT need masterPassword — Ductape mints/rotates
|
|
2982
|
+
its own database user via the Atlas Admin API.
|
|
2983
|
+
|
|
2984
|
+
username (aura-instance only) defaults to "neo4j" if omitted — that IS the correct value for
|
|
2985
|
+
most Aura instances. But this default is NOT a hard guarantee: at least one real Aura instance
|
|
2986
|
+
has been confirmed to authenticate with a different database username instead (its instance id,
|
|
2987
|
+
in the one case observed) and rejects "neo4j" as unauthorized. If a fresh Aura import connects
|
|
2988
|
+
successfully but then fails at runtime with a Neo4j "unauthorized"/access-denied error despite a
|
|
2989
|
+
correct password, don't assume the password is wrong — try re-importing with username set
|
|
2990
|
+
explicitly (ask the user what database username the instance actually uses) before concluding
|
|
2991
|
+
the password itself is bad.
|
|
2923
2992
|
|
|
2924
2993
|
Provision a brand-new resource and register it:
|
|
2925
2994
|
ductape_cli("cloud resources provision-persist-all -f all-envs.json --json")
|
|
@@ -3074,6 +3143,47 @@ Important:
|
|
|
3074
3143
|
- Other services (storage, broker, graph, etc.) resolve $Secret{} references automatically
|
|
3075
3144
|
using the singleton secrets service — no manual resolution needed in most cases.
|
|
3076
3145
|
- Never log or return resolved secret values to end users.
|
|
3146
|
+
|
|
3147
|
+
APPLICATION CODE — PREFER DUCTAPE SECRETS OVER .env / process.env:
|
|
3148
|
+
Once a workspace secret exists for a value, application code should read it through Ductape,
|
|
3149
|
+
not through the process environment or a framework config layer backed by .env. This applies
|
|
3150
|
+
to values the code reads for its own runtime use (API keys, DB passwords, webhook signing
|
|
3151
|
+
secrets, etc.) — it does not apply to non-secret bootstrap config Ductape itself needs to start
|
|
3152
|
+
(DUCTAPE_ACCESS_KEY, DUCTAPE_REDIS_URL, NODE_ENV), which necessarily stay in the environment.
|
|
3153
|
+
|
|
3154
|
+
NestJS: inject with the @Secret() decorator from @ductape/nestjs instead of ConfigService.get()
|
|
3155
|
+
or process.env for any value that is (or should be) a Ductape secret:
|
|
3156
|
+
import { DuctapeSecretsModule, Secret, SecretHandle } from '@ductape/nestjs';
|
|
3157
|
+
|
|
3158
|
+
@Module({ imports: [DuctapeSecretsModule.register({ keys: ['STRIPE_API_KEY'] })] })
|
|
3159
|
+
class PaymentsModule {}
|
|
3160
|
+
|
|
3161
|
+
@Injectable()
|
|
3162
|
+
class PaymentsService {
|
|
3163
|
+
constructor(@Secret('STRIPE_API_KEY') private readonly stripeKey: SecretHandle) {}
|
|
3164
|
+
// this.stripeKey.value holds the decrypted secret
|
|
3165
|
+
}
|
|
3166
|
+
Register every key the module needs via DuctapeSecretsModule.register({ keys: [...] }); each
|
|
3167
|
+
registered key becomes an injectable token resolved through DuctapeContextService, not read
|
|
3168
|
+
from process.env at any point.
|
|
3169
|
+
|
|
3170
|
+
Other runtimes (plain Node/Express/Fastify, Go, Java, .NET, etc.): call
|
|
3171
|
+
ductape_execute("secrets.fetch", ["KEY"]) (or the SDK's secrets.fetch(key) directly in
|
|
3172
|
+
application code) rather than reading process.env / os.Getenv / System.getenv / IConfiguration
|
|
3173
|
+
for a value that is a Ductape secret. Resolve $Secret{KEY} references in config the same way —
|
|
3174
|
+
through the secrets service, never by pre-substituting from the environment.
|
|
3175
|
+
|
|
3176
|
+
MIGRATING EXISTING .env SECRETS: when environment/codebase discovery (see ENVIRONMENTS and
|
|
3177
|
+
SECURITY sections of the migrate-codebase guidance) finds .env entries that look like secrets
|
|
3178
|
+
(API keys, passwords, tokens, connection strings with embedded credentials), do not silently
|
|
3179
|
+
decide either way. Ask the user explicitly whether to create Ductape Secrets from those .env
|
|
3180
|
+
entries — do not assume "yes, migrate everything" or "no, leave .env alone". If the user agrees,
|
|
3181
|
+
import each value locally and specifically:
|
|
3182
|
+
ductape_cli("secrets-import-env --env-file <local-file> --source-key <ENV_KEY> --key <secret-tag> --env <slug> --json")
|
|
3183
|
+
then update application code to read the new secret through @Secret()/secrets.fetch() as above,
|
|
3184
|
+
and only remove the .env entry once that code path is verified working. Never read the .env
|
|
3185
|
+
value into MCP context or an MCP argument while doing this: the CLI command above reads the
|
|
3186
|
+
local file directly and redacts the value from its own output.
|
|
3077
3187
|
`.trim(),
|
|
3078
3188
|
apps: `
|
|
3079
3189
|
DUCTAPE APPS
|
|
@@ -3461,6 +3571,21 @@ SESSION PROPAGATION
|
|
|
3461
3571
|
Feature execution should receive the same explicit actor classification. Never log, serialize
|
|
3462
3572
|
into business payloads, or attach the raw session/refresh token to traces or error messages.
|
|
3463
3573
|
|
|
3574
|
+
FAIL-CLOSED NORMAL SDK RULE
|
|
3575
|
+
Before emitting or approving ordinary @ductape/sdk code for immediate authenticated work, verify
|
|
3576
|
+
that every session-capable runtime call receives the exact ActorContext.session through its
|
|
3577
|
+
top-level session option. This includes api.run/api.dispatch, feature.execute/feature.dispatch,
|
|
3578
|
+
database primitives and saved actions, graph/vector primitives and saved actions, storage,
|
|
3579
|
+
Events, notifications, agents, quotas, and fallbacks where the installed types accept session.
|
|
3580
|
+
Never substitute a session tag, decoded claims, user ID, Authorization header, refresh token, or
|
|
3581
|
+
business-input field. If the original opaque token is unavailable, stop and report the missing
|
|
3582
|
+
propagation boundary; do not silently emit an unattributed user-context call. The only omission
|
|
3583
|
+
allowed is an explicitly classified system context or a version-verified approved delegated flow.
|
|
3584
|
+
|
|
3585
|
+
A Feature receives the session at its execution boundary. Its nested ctx.api, ctx.database,
|
|
3586
|
+
ctx.graph, ctx.vector, ctx.storage, ctx.events, and ctx.notifications calls inherit ctx.session;
|
|
3587
|
+
do not serialize ctx.session into their input objects or hardcode it in the Feature definition.
|
|
3588
|
+
|
|
3464
3589
|
SECURITY BOUNDARY FOR DURABLE WORK
|
|
3465
3590
|
The current SDK accepts a session string on dispatch, but it does not expose an immutable
|
|
3466
3591
|
attribution snapshot API, an expired-token attribution contract, or a general delegated-session
|
|
@@ -4289,8 +4414,11 @@ STEP 6 — WRITE the feature into the project codebase
|
|
|
4289
4414
|
exiting. Ductape cannot run this step for you generically — a Feature handler is real
|
|
4290
4415
|
application code that typically depends on the app's own services, unlike a migration file,
|
|
4291
4416
|
which is declarative data the Ductape backend can apply directly.
|
|
4292
|
-
4.
|
|
4293
|
-
|
|
4417
|
+
4. Run ductape_features_validate_project({ project_dir: "<absolute-project-root>" }). This is a
|
|
4418
|
+
read-only AST conformance gate. Stop on any diagnostic; do not sync or mutate remote state.
|
|
4419
|
+
5. Persist Features by running "ductape features sync" (or ductape_cli("features sync")), which
|
|
4420
|
+
repeats the fail-closed validator, then finds the linked project and runs its "features:sync"
|
|
4421
|
+
script — never automatically on boot.
|
|
4294
4422
|
Pass an optional filter argument to scope it: "ductape features sync payments".
|
|
4295
4423
|
|
|
4296
4424
|
A NestJS service with both concerns split looks like:
|
|
@@ -4322,7 +4450,8 @@ STEP 7 — HANDLE conditionals, loops, and branching (when applicable)
|
|
|
4322
4450
|
6. Direct Date.now() and Math.random() are forbidden in handlers. Use ctx.transform.now(),
|
|
4323
4451
|
ctx.transform.uuid(), and ctx.transform.concat/replace/substring/upper/lower/trim so values
|
|
4324
4452
|
are generated at execution time. Use a portable Function for domain-specific generators.
|
|
4325
|
-
7. Never use branchOverrides in newly generated code.
|
|
4453
|
+
7. Never use branchOverrides or recordScenarios in newly generated code. Existing handlers must
|
|
4454
|
+
set controlFlowMode: "legacy" explicitly while they are being migrated.
|
|
4326
4455
|
|
|
4327
4456
|
Prefer explicit portable branching for step results:
|
|
4328
4457
|
const result = await ctx.step("find", () => ctx.database.execute(...));
|
|
@@ -4979,7 +5108,7 @@ connect to the proxy's /realtime gateway. Never use this package on the server
|
|
|
4979
5108
|
for server-side code.
|
|
4980
5109
|
|
|
4981
5110
|
INSTALL
|
|
4982
|
-
npm install @ductape/client
|
|
5111
|
+
npm install @ductape/client@latest
|
|
4983
5112
|
|
|
4984
5113
|
INITIALIZATION
|
|
4985
5114
|
import { createClient } from '@ductape/client';
|
|
@@ -5142,7 +5271,7 @@ React hooks and context provider for Ductape. Wraps @ductape/client.
|
|
|
5142
5271
|
Requires react >= 17.
|
|
5143
5272
|
|
|
5144
5273
|
INSTALL
|
|
5145
|
-
npm install @ductape/react
|
|
5274
|
+
npm install @ductape/react@latest
|
|
5146
5275
|
|
|
5147
5276
|
SETUP — wrap your app root with DuctapeProvider
|
|
5148
5277
|
import { DuctapeProvider } from '@ductape/react';
|
|
@@ -5346,7 +5475,7 @@ Vue 3 composables and plugin for Ductape. Wraps @ductape/client.
|
|
|
5346
5475
|
Requires vue >= 3.
|
|
5347
5476
|
|
|
5348
5477
|
INSTALL
|
|
5349
|
-
npm install @ductape/vue
|
|
5478
|
+
npm install @ductape/vue@latest
|
|
5350
5479
|
|
|
5351
5480
|
SETUP — install the plugin at app root
|
|
5352
5481
|
// main.ts
|
|
@@ -5924,8 +6053,8 @@ const eventsTopicSetupHandler = async (args) => {
|
|
|
5924
6053
|
return {
|
|
5925
6054
|
content: [{ type: 'text', text: JSON.stringify({
|
|
5926
6055
|
ok: true,
|
|
5927
|
-
project_root: cliCwd(),
|
|
5928
|
-
path: join(cliCwd(), relativePath),
|
|
6056
|
+
project_root: cliCwd(args.project_dir),
|
|
6057
|
+
path: join(cliCwd(args.project_dir), relativePath),
|
|
5929
6058
|
relative_path: relativePath,
|
|
5930
6059
|
definition,
|
|
5931
6060
|
create_command: `ductape events topics create -f ${relativePath} --json`,
|
|
@@ -5946,12 +6075,37 @@ const eventsProjectValidationHandler = async (args) => {
|
|
|
5946
6075
|
isError: true,
|
|
5947
6076
|
};
|
|
5948
6077
|
}
|
|
5949
|
-
const result = runCli('events topics validate --dir ductape/events --json');
|
|
6078
|
+
const result = runCli('events topics validate --dir ductape/events --json', args.project_dir);
|
|
5950
6079
|
return {
|
|
5951
6080
|
content: [{ type: 'text', text: result.output || '(no output)' }],
|
|
5952
6081
|
...(result.success ? {} : { isError: true }),
|
|
5953
6082
|
};
|
|
5954
6083
|
};
|
|
6084
|
+
const featuresProjectValidationHandler = async (args) => {
|
|
6085
|
+
const result = runCli('features validate --json', args.project_dir);
|
|
6086
|
+
return {
|
|
6087
|
+
content: [{ type: 'text', text: result.output || '(no output)' }],
|
|
6088
|
+
...(result.success ? {} : { isError: true }),
|
|
6089
|
+
};
|
|
6090
|
+
};
|
|
6091
|
+
const frontendAnalyticsProjectValidationHandler = async (args) => {
|
|
6092
|
+
try {
|
|
6093
|
+
const result = validateFrontendAnalyticsProject(args.project_dir);
|
|
6094
|
+
return {
|
|
6095
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
6096
|
+
...(result.valid ? {} : { isError: true }),
|
|
6097
|
+
};
|
|
6098
|
+
}
|
|
6099
|
+
catch (error) {
|
|
6100
|
+
return {
|
|
6101
|
+
content: [{
|
|
6102
|
+
type: 'text',
|
|
6103
|
+
text: error instanceof Error ? error.message : String(error),
|
|
6104
|
+
}],
|
|
6105
|
+
isError: true,
|
|
6106
|
+
};
|
|
6107
|
+
}
|
|
6108
|
+
};
|
|
5955
6109
|
const cliInputSchema = z.object({
|
|
5956
6110
|
command: z.string().describe('The ductape CLI command to run, without the leading "ductape" word. ' +
|
|
5957
6111
|
'Examples: "products list", "products create --name \\"My Product\\" --tag my-product", ' +
|
|
@@ -5965,13 +6119,22 @@ const cliInputSchema = z.object({
|
|
|
5965
6119
|
'no linked project required — the product tag is always an explicit argument). Quotas, ' +
|
|
5966
6120
|
'fallbacks, jobs, and healthchecks use resources commands. App actions and auths are ' +
|
|
5967
6121
|
'configured in the Workbench UI. Features have no CLI creation command: define them in ' +
|
|
5968
|
-
'application code with features.define under ductape/features/.
|
|
6122
|
+
'application code with features.define under ductape/features/. Always run "features validate" first; ' +
|
|
6123
|
+
'it performs read-only AST conformance checks and "features sync" repeats the same fail-closed preflight. Persist them with ' +
|
|
5969
6124
|
'"features sync" (runs the project\'s own "features:sync" npm script) — never call ' +
|
|
5970
6125
|
'features.define from the app\'s normal startup path, since that blocks every boot on ' +
|
|
5971
6126
|
'Ductape API reachability. See ductape_docs for the full convention.\n\n' +
|
|
5972
6127
|
'The CLI uses the user\'s local logged-in session. Prefer browser OAuth in a trusted local terminal: ' +
|
|
5973
6128
|
'ductape login --browser google (or github). It returns automatically through a validated loopback callback. ' +
|
|
5974
6129
|
'Never invoke interactive login through MCP or ask the user for credentials.'),
|
|
6130
|
+
project_dir: z.string().optional().describe('Absolute path to the project this command should run against (e.g. "ductape/events" is ' +
|
|
6131
|
+
'resolved relative to this directory, and product/project linking state is read from it). ' +
|
|
6132
|
+
'The MCP server is a long-running process — its own working directory (or a static ' +
|
|
6133
|
+
'DUCTAPE_PROJECT_DIR env var, if set) does NOT follow which project the current conversation ' +
|
|
6134
|
+
'is actually working in. Pass this whenever the command is project-relative (events topics, ' +
|
|
6135
|
+
'anything reading ductape/ files, link/unlink) and the session is working in a project other ' +
|
|
6136
|
+
'than wherever the MCP server happened to start. Omit only for purely workspace-level commands ' +
|
|
6137
|
+
'(e.g. "products list") where no project directory is relevant.'),
|
|
5975
6138
|
});
|
|
5976
6139
|
async function loadMcpSdk() {
|
|
5977
6140
|
try {
|
|
@@ -6116,7 +6279,7 @@ async function main() {
|
|
|
6116
6279
|
],
|
|
6117
6280
|
}));
|
|
6118
6281
|
const cliHandler = async (args) => {
|
|
6119
|
-
const cli = checkCli();
|
|
6282
|
+
const cli = checkCli(args.project_dir);
|
|
6120
6283
|
if (!cli.available) {
|
|
6121
6284
|
return {
|
|
6122
6285
|
content: [{
|
|
@@ -6165,7 +6328,7 @@ async function main() {
|
|
|
6165
6328
|
// The user may complete `ductape login` in another terminal while this MCP process remains
|
|
6166
6329
|
// alive; caching "none" would otherwise make the MCP blind to the newly written session.
|
|
6167
6330
|
if (authState === 'unknown' || authState === 'none') {
|
|
6168
|
-
checkLoginState();
|
|
6331
|
+
checkLoginState(args.project_dir);
|
|
6169
6332
|
}
|
|
6170
6333
|
if (authState === 'none') {
|
|
6171
6334
|
const wsFlag = process.env.DUCTAPE_WORKSPACE ? ` --workspace "${process.env.DUCTAPE_WORKSPACE}"` : '';
|
|
@@ -6193,10 +6356,10 @@ async function main() {
|
|
|
6193
6356
|
}
|
|
6194
6357
|
// Sync to the configured workspace once per process (best-effort)
|
|
6195
6358
|
if (!workspaceSynced) {
|
|
6196
|
-
syncWorkspace();
|
|
6359
|
+
syncWorkspace(args.project_dir);
|
|
6197
6360
|
}
|
|
6198
6361
|
}
|
|
6199
|
-
const result = runCli(args.command);
|
|
6362
|
+
const result = runCli(args.command, args.project_dir);
|
|
6200
6363
|
// Update cached state after auth commands
|
|
6201
6364
|
if (firstWord === 'login' && result.success) {
|
|
6202
6365
|
authState = 'ok';
|
|
@@ -6786,6 +6949,23 @@ async function main() {
|
|
|
6786
6949
|
inputSchema: eventsProjectValidationInputSchema,
|
|
6787
6950
|
annotations: readOnlyLocalAnnotations,
|
|
6788
6951
|
}, eventsProjectValidationHandler);
|
|
6952
|
+
server.registerTool('ductape_features_validate_project', {
|
|
6953
|
+
title: 'Validate Ductape Feature Project',
|
|
6954
|
+
description: 'Read-only AST validation of code-first Features under ductape/features/. Detects native branching, loops, ' +
|
|
6955
|
+
'collection callbacks, host-bound time/random/environment access, and legacy recording options before sync. ' +
|
|
6956
|
+
'A failed result means features sync must not be run.',
|
|
6957
|
+
inputSchema: featuresProjectValidationInputSchema,
|
|
6958
|
+
annotations: readOnlyLocalAnnotations,
|
|
6959
|
+
}, featuresProjectValidationHandler);
|
|
6960
|
+
server.registerTool('ductape_frontend_analytics_validate_project', {
|
|
6961
|
+
title: 'Validate Ductape Frontend Analytics',
|
|
6962
|
+
description: 'Read-only audit of every frontend package using @ductape/react, @ductape/vue, or the browser client. ' +
|
|
6963
|
+
'Requires an explicit enabled/deferred/prohibited decision per application and validates analytics identity, ' +
|
|
6964
|
+
'logout, pageview, named-event, client-failure, and auto-capture privacy signals when enabled. ' +
|
|
6965
|
+
'Returns actionable findings and never modifies source.',
|
|
6966
|
+
inputSchema: frontendAnalyticsProjectValidationInputSchema,
|
|
6967
|
+
annotations: readOnlyLocalAnnotations,
|
|
6968
|
+
}, frontendAnalyticsProjectValidationHandler);
|
|
6789
6969
|
server.registerTool('ductape_function_setup', {
|
|
6790
6970
|
title: 'Ductape Portable Function Setup',
|
|
6791
6971
|
description: 'Read-only: generate (without applying) the mandatory secure local + remote runtime setup for application functions used by Features. ' +
|
|
@@ -6971,8 +7151,14 @@ async function main() {
|
|
|
6971
7151
|
' can never be retrieved again via the Aura API — Ductape has no way to source it automatically.\n' +
|
|
6972
7152
|
' Always ask the user for the instance password before calling import-persist-all for an\n' +
|
|
6973
7153
|
' aura-instance entry; do not attempt the import without it, it will fail with a clear error\n' +
|
|
6974
|
-
' naming exactly this
|
|
6975
|
-
'
|
|
7154
|
+
' naming exactly this.\n' +
|
|
7155
|
+
' username defaults to "neo4j" if omitted, which is correct for most Aura instances — but this\n' +
|
|
7156
|
+
' is NOT a hard guarantee. At least one real instance has been confirmed to use a different\n' +
|
|
7157
|
+
' database username instead (its instance id, in the one case observed) and rejects "neo4j" as\n' +
|
|
7158
|
+
' unauthorized. If the import succeeds but connect later fails with a Neo4j\n' +
|
|
7159
|
+
' unauthorized/access-denied error despite a correct password, re-import with username set\n' +
|
|
7160
|
+
' explicitly (ask the user what database username the instance actually uses) rather than\n' +
|
|
7161
|
+
' assuming the password is wrong.\n' +
|
|
6976
7162
|
' Step 1 — discover the instance:\n' +
|
|
6977
7163
|
' ductape_cli("cloud resources list -f /tmp/aura-list.json --json")\n' +
|
|
6978
7164
|
' File: {"cloud": "<aura-connection-tag>", "service": "aura-instance"}\n' +
|
|
@@ -6983,7 +7169,9 @@ async function main() {
|
|
|
6983
7169
|
' explicitly wants different Aura instances per environment):\n' +
|
|
6984
7170
|
' cloud (connection tag), service: "aura-instance", type: "graphs",\n' +
|
|
6985
7171
|
' product, component (new or existing graph tag), env, resource (instance id/name from\n' +
|
|
6986
|
-
' Step 1), masterPassword (the instance password — ask the user, do not guess or omit)
|
|
7172
|
+
' Step 1), masterPassword (the instance password — ask the user, do not guess or omit),\n' +
|
|
7173
|
+
' username (optional — omit for the "neo4j" default; only set if the user tells you the\n' +
|
|
7174
|
+
' instance uses a different one, or if a prior attempt with "neo4j" failed to authenticate).\n' +
|
|
6987
7175
|
' Example (two envs, same Aura instance):\n' +
|
|
6988
7176
|
' [{"cloud":"aura-tag","service":"aura-instance","type":"graphs",\n' +
|
|
6989
7177
|
' "product":"my-product","component":"commerce-network","env":"snd",\n' +
|
|
@@ -7027,6 +7215,8 @@ async function main() {
|
|
|
7027
7215
|
server.tool('ductape_events_discover', eventsDiscoveryInputSchema.shape, eventsDiscoveryHandler);
|
|
7028
7216
|
server.tool('ductape_events_topic_setup', eventsTopicSetupInputSchema.shape, readOnlyLocalAnnotations, eventsTopicSetupHandler);
|
|
7029
7217
|
server.tool('ductape_events_validate_project', eventsProjectValidationInputSchema.shape, readOnlyLocalAnnotations, eventsProjectValidationHandler);
|
|
7218
|
+
server.tool('ductape_features_validate_project', featuresProjectValidationInputSchema.shape, readOnlyLocalAnnotations, featuresProjectValidationHandler);
|
|
7219
|
+
server.tool('ductape_frontend_analytics_validate_project', frontendAnalyticsProjectValidationInputSchema.shape, readOnlyLocalAnnotations, frontendAnalyticsProjectValidationHandler);
|
|
7030
7220
|
server.tool('ductape_function_setup', portableFunctionSetupInputSchema.shape, portableFunctionSetupAnnotations, portableFunctionSetupHandler);
|
|
7031
7221
|
server.tool('ductape_redis_setup', redisSetupInputSchema.shape, redisSetupHandler);
|
|
7032
7222
|
server.tool('ductape_migration_plan', migrationInputSchema.shape, migrationHandler);
|
package/docs/TOOLS.md
CHANGED
|
@@ -74,6 +74,19 @@ manifest, envelope registry, or custom event registry.
|
|
|
74
74
|
|
|
75
75
|
---
|
|
76
76
|
|
|
77
|
+
## Tool: `ductape_frontend_analytics_validate_project`
|
|
78
|
+
|
|
79
|
+
Audits every deployable Ductape React, Vue, or browser-client application under an absolute project
|
|
80
|
+
directory. Each application must have an explicit entry in `ductape/analytics/frontend.json` with
|
|
81
|
+
status `enabled`, `deferred`, or `prohibited`. Deferred and prohibited choices require a rationale.
|
|
82
|
+
|
|
83
|
+
Enabled applications are checked for Ductape analytics usage, router pageviews, named events,
|
|
84
|
+
`identify(fullSessionToken)` after authenticated session creation/refresh, `clearSession()` on
|
|
85
|
+
logout/account switching, sanitized client-failure telemetry, and reviewed auto-capture. The tool
|
|
86
|
+
is read-only and never edits application source.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
77
90
|
## Tool: `ductape_function_setup`
|
|
78
91
|
|
|
79
92
|
Produces the mandatory local registry and signed HTTPS exposure plan for portable application
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ductape/mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "MCP server that exposes Ductape SDK operations via the backend proxy",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
],
|
|
16
16
|
"scripts": {
|
|
17
17
|
"build": "tsc",
|
|
18
|
-
"test": "npm run build && node scripts/check-cli-command-security.mjs && node scripts/check-frontend-analytics-guidance.mjs && node scripts/check-events-discovery.mjs && node scripts/check-schema-fallback.mjs && node scripts/check-paystack-action-schema.mjs && node scripts/check-portable-functions.mjs && node scripts/check-feature-control-flow.mjs && node scripts/check-project-link-guidance.mjs && node scripts/check-graph-vector-projection-guidance.mjs && node scripts/check-asset-file-guidance.mjs && node scripts/check-database-action-contract-guidance.mjs && node scripts/check-runtime-sync-guidance.mjs && node scripts/check-runtime-input-recovery.mjs && node scripts/check-resilience-health-guidance.mjs",
|
|
18
|
+
"test": "npm run build && node scripts/check-cli-command-security.mjs && node scripts/check-doctor-e2e.mjs && node scripts/check-frontend-analytics-guidance.mjs && node scripts/check-frontend-analytics-validator.mjs && node scripts/check-events-discovery.mjs && node scripts/check-schema-fallback.mjs && node scripts/check-paystack-action-schema.mjs && node scripts/check-portable-functions.mjs && node scripts/check-feature-control-flow.mjs && node scripts/check-project-link-guidance.mjs && node scripts/check-graph-vector-projection-guidance.mjs && node scripts/check-asset-file-guidance.mjs && node scripts/check-database-action-contract-guidance.mjs && node scripts/check-runtime-sync-guidance.mjs && node scripts/check-runtime-input-recovery.mjs && node scripts/check-resilience-health-guidance.mjs && node scripts/check-session-propagation-guidance.mjs",
|
|
19
19
|
"start": "node dist/index.js",
|
|
20
20
|
"dev": "tsx src/index.ts"
|
|
21
21
|
},
|