@viberqc/mcp-server 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.
@@ -0,0 +1,63 @@
1
+ const SENSITIVE_KEY = /^(?:absolutePath|authorization|cookie|password|privateKey|secret|sourceCode|token)$/i;
2
+ const WINDOWS_ABSOLUTE_PATH = /(?:^|[\s"'(])[a-zA-Z]:[\\/][^\s"']+/;
3
+ const WINDOWS_ABSOLUTE_PATH_GLOBAL = /(?:^|[\s"'(])[a-zA-Z]:[\\/][^\s"']+/g;
4
+ const POSIX_ABSOLUTE_PATH = /(?:^|[\s"'(])\/(?!\/)[^\s"']+/;
5
+ const POSIX_ABSOLUTE_PATH_GLOBAL = /(?:^|[\s"'(])\/(?!\/)[^\s"']+/g;
6
+ const PROJECT_TOKEN = /\bvqc_pt_[A-Za-z0-9_-]+\b/g;
7
+ const BEARER_TOKEN = /\bBearer\s+[A-Za-z0-9._~+/-]+=*\b/gi;
8
+ const PRIVATE_KEY_BLOCK = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g;
9
+ const SECRET_ASSIGNMENT = /\b(?:api[_-]?key|client[_-]?secret|password|token)\s*[:=]\s*[^\s,;]+/gi;
10
+ export class ExternalPayloadRejectedError extends Error {
11
+ code = 'EXTERNAL_PAYLOAD_REJECTED';
12
+ constructor() {
13
+ super('The external payload contains local or sensitive data.');
14
+ this.name = 'ExternalPayloadRejectedError';
15
+ }
16
+ }
17
+ function stringContainsLocalOrSensitiveData(value) {
18
+ return (WINDOWS_ABSOLUTE_PATH.test(value) ||
19
+ POSIX_ABSOLUTE_PATH.test(value) ||
20
+ /\bvqc_pt_[A-Za-z0-9_-]+\b/.test(value) ||
21
+ /\bBearer\s+[A-Za-z0-9._~+/-]+=*\b/i.test(value) ||
22
+ /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/.test(value) ||
23
+ /\b(?:api[_-]?key|client[_-]?secret|password|token)\s*[:=]\s*[^\s,;]+/i.test(value));
24
+ }
25
+ function inspectExternalValue(value, seen) {
26
+ if (typeof value === 'string') {
27
+ if (stringContainsLocalOrSensitiveData(value)) {
28
+ throw new ExternalPayloadRejectedError();
29
+ }
30
+ return;
31
+ }
32
+ if (value === null || typeof value !== 'object') {
33
+ return;
34
+ }
35
+ if (seen.has(value)) {
36
+ throw new ExternalPayloadRejectedError();
37
+ }
38
+ seen.add(value);
39
+ if (Array.isArray(value)) {
40
+ for (const item of value) {
41
+ inspectExternalValue(item, seen);
42
+ }
43
+ return;
44
+ }
45
+ for (const [key, item] of Object.entries(value)) {
46
+ if (SENSITIVE_KEY.test(key)) {
47
+ throw new ExternalPayloadRejectedError();
48
+ }
49
+ inspectExternalValue(item, seen);
50
+ }
51
+ }
52
+ export function assertExternalPayloadSafe(value) {
53
+ inspectExternalValue(value, new Set());
54
+ }
55
+ export function redactSensitiveText(value) {
56
+ return value
57
+ .replace(PRIVATE_KEY_BLOCK, '[REDACTED_PRIVATE_KEY]')
58
+ .replace(BEARER_TOKEN, 'Bearer [REDACTED]')
59
+ .replace(PROJECT_TOKEN, '[REDACTED_PROJECT_TOKEN]')
60
+ .replace(SECRET_ASSIGNMENT, '[REDACTED_SECRET]')
61
+ .replace(WINDOWS_ABSOLUTE_PATH_GLOBAL, ' [REDACTED_LOCAL_PATH]')
62
+ .replace(POSIX_ABSOLUTE_PATH_GLOBAL, ' [REDACTED_LOCAL_PATH]');
63
+ }
@@ -0,0 +1,100 @@
1
+ import { getSessionInputSchema, getSessionOutputSchema, inspectRepoInputSchema, inspectRepoOutputSchema, listProfilesInputSchema, listProfilesOutputSchema, prepareScanInputSchema, prepareScanOutputSchema, statusInputSchema, statusOutputSchema, } from '../contracts/tools.js';
2
+ import { redactSensitiveText } from '../security/redaction.js';
3
+ import { inspectRepository, repositoryContext } from './repository-inspection.js';
4
+ const LOCAL_READ_ONLY = {
5
+ readOnlyHint: true,
6
+ destructiveHint: false,
7
+ idempotentHint: true,
8
+ openWorldHint: false,
9
+ };
10
+ const NETWORK_READ_ONLY = {
11
+ readOnlyHint: true,
12
+ destructiveHint: false,
13
+ idempotentHint: true,
14
+ openWorldHint: true,
15
+ };
16
+ function success(value) {
17
+ return {
18
+ content: [{ type: 'text', text: JSON.stringify(value) }],
19
+ structuredContent: value,
20
+ };
21
+ }
22
+ function failure(error) {
23
+ const record = typeof error === 'object' && error !== null
24
+ ? error
25
+ : {};
26
+ const code = typeof record.code === 'string' &&
27
+ /^[A-Z][A-Z0-9_]{2,63}$/.test(record.code)
28
+ ? record.code
29
+ : 'MCP_REQUEST_FAILED';
30
+ const rawMessage = typeof record.code === 'string' && typeof record.message === 'string'
31
+ ? record.message
32
+ : 'The ViberQC request could not be completed.';
33
+ const message = redactSensitiveText(rawMessage).slice(0, 500);
34
+ return {
35
+ content: [{ type: 'text', text: JSON.stringify({ code, message }) }],
36
+ isError: true,
37
+ };
38
+ }
39
+ async function run(operation) {
40
+ try {
41
+ return success(await operation());
42
+ }
43
+ catch (error) {
44
+ return failure(error);
45
+ }
46
+ }
47
+ export function registerP2Tools(server, dependencies) {
48
+ server.registerTool('viberqc_status', {
49
+ title: 'ViberQC status',
50
+ description: 'Check the MCP version, account, plan, Project, Repository, and allowed actions.',
51
+ inputSchema: statusInputSchema,
52
+ outputSchema: statusOutputSchema,
53
+ annotations: NETWORK_READ_ONLY,
54
+ }, async () => run(async () => {
55
+ const context = await repositoryContext(await dependencies.rootSources(), 'changed');
56
+ return dependencies.api.status(context);
57
+ }));
58
+ server.registerTool('viberqc_list_profiles', {
59
+ title: 'List ViberQC scan profiles',
60
+ description: 'List profiles and classify supported, unsupported, and manual item IDs.',
61
+ inputSchema: listProfilesInputSchema,
62
+ outputSchema: listProfilesOutputSchema,
63
+ annotations: NETWORK_READ_ONLY,
64
+ }, async (input) => run(() => dependencies.api.listProfiles(listProfilesInputSchema.parse(input))));
65
+ server.registerTool('viberqc_inspect_repo', {
66
+ title: 'Inspect the current repository',
67
+ description: 'Read local Git metadata, detected tools, and file counts without producing pass or fail findings.',
68
+ inputSchema: inspectRepoInputSchema,
69
+ outputSchema: inspectRepoOutputSchema,
70
+ annotations: LOCAL_READ_ONLY,
71
+ }, async (input) => run(async () => {
72
+ const parsed = inspectRepoInputSchema.parse(input);
73
+ return inspectRepository(await dependencies.rootSources(), parsed.scope);
74
+ }));
75
+ server.registerTool('viberqc_prepare_scan', {
76
+ title: 'Prepare a ViberQC scan session',
77
+ description: 'Create a scan session from safe Repository metadata without uploading Source Code.',
78
+ inputSchema: prepareScanInputSchema,
79
+ outputSchema: prepareScanOutputSchema,
80
+ annotations: NETWORK_READ_ONLY,
81
+ }, async (input) => run(async () => {
82
+ const parsed = prepareScanInputSchema.parse(input);
83
+ const repository = await repositoryContext(await dependencies.rootSources(), parsed.scope);
84
+ return dependencies.api.prepareScan({
85
+ ...parsed,
86
+ repository,
87
+ sourceUpload: false,
88
+ });
89
+ }));
90
+ server.registerTool('viberqc_get_session', {
91
+ title: 'Get a ViberQC scan session',
92
+ description: 'Read a prepared scan session and its evidence summary without changing files.',
93
+ inputSchema: getSessionInputSchema,
94
+ outputSchema: getSessionOutputSchema,
95
+ annotations: NETWORK_READ_ONLY,
96
+ }, async (input) => run(() => {
97
+ const parsed = getSessionInputSchema.parse(input);
98
+ return dependencies.api.getSession(parsed.sessionId);
99
+ }));
100
+ }
@@ -0,0 +1,223 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { execFile } from 'node:child_process';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { promisify } from 'node:util';
5
+ import { buildExternalRepositorySummary, buildRepositoryInventory, } from '../repository/inventory.js';
6
+ import { RepositoryIdentityError, resolveRepositoryIdentity, } from '../repository/identity.js';
7
+ import { validateRepositoryFile, validateRepositoryRoot, } from '../security/path-policy.js';
8
+ import { assertExternalPayloadSafe } from '../security/redaction.js';
9
+ const execFileAsync = promisify(execFile);
10
+ const MANIFEST_LIMIT_BYTES = 1024 * 1024;
11
+ async function git(root, args) {
12
+ const result = await execFileAsync('git', ['-C', root.absolutePath, ...args], {
13
+ encoding: 'utf8',
14
+ maxBuffer: 20 * 1024 * 1024,
15
+ });
16
+ return result.stdout;
17
+ }
18
+ async function readGitState(root) {
19
+ const [branchText, commitText, statusText] = await Promise.all([
20
+ git(root, ['branch', '--show-current']),
21
+ git(root, ['rev-parse', 'HEAD']),
22
+ git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all']),
23
+ ]);
24
+ const dirtyStatus = { staged: 0, unstaged: 0, untracked: 0 };
25
+ const records = statusText.split('\0').filter(Boolean);
26
+ for (let index = 0; index < records.length; index += 1) {
27
+ const record = records[index] ?? '';
28
+ const x = record[0] ?? ' ';
29
+ const y = record[1] ?? ' ';
30
+ if (x === '?' && y === '?') {
31
+ dirtyStatus.untracked += 1;
32
+ continue;
33
+ }
34
+ if (x !== ' ') {
35
+ dirtyStatus.staged += 1;
36
+ }
37
+ if (y !== ' ') {
38
+ dirtyStatus.unstaged += 1;
39
+ }
40
+ if (x === 'R' || x === 'C') {
41
+ index += 1;
42
+ }
43
+ }
44
+ return {
45
+ branch: branchText.trim() || 'detached',
46
+ commitSha: commitText.trim(),
47
+ dirtyStatus,
48
+ };
49
+ }
50
+ async function trackedPaths(root) {
51
+ return (await git(root, ['ls-files', '-z']))
52
+ .split('\0')
53
+ .filter((entry) => entry.length > 0);
54
+ }
55
+ function detectLanguages(paths) {
56
+ const extensions = new Map([
57
+ ['.c', 'c'],
58
+ ['.cpp', 'cpp'],
59
+ ['.cs', 'csharp'],
60
+ ['.go', 'go'],
61
+ ['.java', 'java'],
62
+ ['.js', 'javascript'],
63
+ ['.jsx', 'javascript'],
64
+ ['.kt', 'kotlin'],
65
+ ['.php', 'php'],
66
+ ['.py', 'python'],
67
+ ['.rb', 'ruby'],
68
+ ['.rs', 'rust'],
69
+ ['.swift', 'swift'],
70
+ ['.ts', 'typescript'],
71
+ ['.tsx', 'typescript'],
72
+ ]);
73
+ const detected = new Set();
74
+ for (const filePath of paths) {
75
+ const dot = filePath.lastIndexOf('.');
76
+ const extension = dot >= 0 ? filePath.slice(dot).toLowerCase() : '';
77
+ const language = extensions.get(extension);
78
+ if (language) {
79
+ detected.add(language);
80
+ }
81
+ }
82
+ return [...detected].sort();
83
+ }
84
+ function detectFrameworks(paths) {
85
+ const names = new Set(paths.map((value) => value.toLowerCase()));
86
+ const frameworks = new Set();
87
+ if ([...names].some((name) => /(^|\/)next\.config\.[^/]+$/.test(name))) {
88
+ frameworks.add('nextjs');
89
+ }
90
+ if (names.has('angular.json')) {
91
+ frameworks.add('angular');
92
+ }
93
+ if ([...names].some((name) => /(^|\/)(nuxt|svelte|vite)\.config\.[^/]+$/.test(name))) {
94
+ if ([...names].some((name) => /(^|\/)nuxt\.config\.[^/]+$/.test(name))) {
95
+ frameworks.add('nuxt');
96
+ }
97
+ if ([...names].some((name) => /(^|\/)svelte\.config\.[^/]+$/.test(name))) {
98
+ frameworks.add('svelte');
99
+ }
100
+ if ([...names].some((name) => /(^|\/)vite\.config\.[^/]+$/.test(name))) {
101
+ frameworks.add('vite');
102
+ }
103
+ }
104
+ return [...frameworks].sort();
105
+ }
106
+ function detectPackageManagers(paths) {
107
+ const names = new Set(paths.map((value) => value.toLowerCase()));
108
+ const managers = [];
109
+ if (names.has('pnpm-lock.yaml'))
110
+ managers.push('pnpm');
111
+ if (names.has('yarn.lock'))
112
+ managers.push('yarn');
113
+ if (names.has('bun.lock') || names.has('bun.lockb'))
114
+ managers.push('bun');
115
+ if (names.has('package-lock.json'))
116
+ managers.push('npm');
117
+ if (names.has('poetry.lock'))
118
+ managers.push('poetry');
119
+ if (names.has('uv.lock'))
120
+ managers.push('uv');
121
+ if (names.has('cargo.lock'))
122
+ managers.push('cargo');
123
+ if (names.has('go.sum'))
124
+ managers.push('go');
125
+ return managers;
126
+ }
127
+ async function detectTestCommands(root, paths, packageManagers) {
128
+ if (!paths.includes('package.json')) {
129
+ return [];
130
+ }
131
+ try {
132
+ const manifest = await validateRepositoryFile(root, 'package.json');
133
+ const raw = await readFile(manifest.absolutePath, 'utf8');
134
+ if (Buffer.byteLength(raw, 'utf8') > MANIFEST_LIMIT_BYTES) {
135
+ return [];
136
+ }
137
+ const parsed = JSON.parse(raw);
138
+ const scriptNames = Object.keys(parsed.scripts ?? {});
139
+ const allowed = [
140
+ 'test',
141
+ 'lint',
142
+ 'build',
143
+ 'typecheck',
144
+ 'scan:inventory',
145
+ 'typecheck:service-scan',
146
+ ].filter((name) => scriptNames.includes(name));
147
+ const manager = packageManagers[0] ?? 'npm';
148
+ return allowed.map((name) => {
149
+ if (manager === 'pnpm')
150
+ return `pnpm ${name}`;
151
+ if (manager === 'yarn')
152
+ return `yarn ${name}`;
153
+ if (manager === 'bun')
154
+ return `bun run ${name}`;
155
+ return name === 'test' ? 'npm test' : `npm run ${name}`;
156
+ });
157
+ }
158
+ catch {
159
+ return [];
160
+ }
161
+ }
162
+ async function repositoryFingerprint(root) {
163
+ let material;
164
+ try {
165
+ const identity = await resolveRepositoryIdentity(root);
166
+ material = `${identity.kind}:${identity.value}`;
167
+ }
168
+ catch (error) {
169
+ if (!(error instanceof RepositoryIdentityError) ||
170
+ error.code !== 'LOCAL_ID_REQUIRED') {
171
+ throw error;
172
+ }
173
+ const firstCommit = (await git(root, [
174
+ 'rev-list',
175
+ '--max-parents=0',
176
+ '--reverse',
177
+ 'HEAD',
178
+ ]))
179
+ .split('\n')
180
+ .find(Boolean);
181
+ if (!firstCommit) {
182
+ throw error;
183
+ }
184
+ material = `local-history:${firstCommit}`;
185
+ }
186
+ return createHash('sha256').update(material).digest('hex');
187
+ }
188
+ export async function inspectRepository(sources, scope) {
189
+ const root = await validateRepositoryRoot(sources);
190
+ const [gitState, inventory, paths, fingerprint] = await Promise.all([
191
+ readGitState(root),
192
+ buildRepositoryInventory(root, { scope }),
193
+ trackedPaths(root),
194
+ repositoryFingerprint(root),
195
+ ]);
196
+ const summary = buildExternalRepositorySummary(inventory);
197
+ const packageManagers = detectPackageManagers(paths);
198
+ const output = {
199
+ ...gitState,
200
+ techStack: {
201
+ languages: detectLanguages(paths),
202
+ frameworks: detectFrameworks(paths),
203
+ packageManagers,
204
+ testCommands: await detectTestCommands(root, paths, packageManagers),
205
+ },
206
+ scope: summary.scope,
207
+ fileCount: summary.fileCount,
208
+ skippedCount: summary.skippedCount,
209
+ skipReasons: summary.skipReasons,
210
+ repoFingerprint: fingerprint,
211
+ };
212
+ assertExternalPayloadSafe(output);
213
+ return output;
214
+ }
215
+ export async function repositoryContext(sources, scope) {
216
+ const inspected = await inspectRepository(sources, scope);
217
+ return {
218
+ repositoryFingerprint: inspected.repoFingerprint,
219
+ branch: inspected.branch,
220
+ commitSha: inspected.commitSha,
221
+ scope: inspected.scope,
222
+ };
223
+ }
@@ -0,0 +1,43 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { MCP_PACKAGE_VERSION, MCP_SERVER_NAME, } from '../contracts/tools.js';
4
+ import { HttpViberQcApi } from '../client/api.js';
5
+ import { registerP2Tools } from './register.js';
6
+ function fileRoots(roots) {
7
+ const paths = [];
8
+ for (const root of roots) {
9
+ try {
10
+ const parsed = new URL(root.uri);
11
+ if (parsed.protocol === 'file:') {
12
+ paths.push(fileURLToPath(parsed));
13
+ }
14
+ }
15
+ catch {
16
+ // An invalid or non-file root is not trusted.
17
+ }
18
+ }
19
+ return paths;
20
+ }
21
+ export function createViberQcServer(options = {}) {
22
+ const server = new McpServer({
23
+ name: MCP_SERVER_NAME,
24
+ version: MCP_PACKAGE_VERSION,
25
+ });
26
+ const rootSources = options.rootSources ??
27
+ (async () => {
28
+ let mcpRoots = [];
29
+ if (server.server.getClientCapabilities()?.roots) {
30
+ const result = await server.server.listRoots();
31
+ mcpRoots = fileRoots(result.roots);
32
+ }
33
+ return {
34
+ mcpRoots,
35
+ configuredRoot: process.env.VIBERQC_REPO_ROOT,
36
+ };
37
+ });
38
+ registerP2Tools(server, {
39
+ api: options.api ?? new HttpViberQcApi(),
40
+ rootSources,
41
+ });
42
+ return server;
43
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@viberqc/mcp-server",
3
+ "version": "0.1.0",
4
+ "description": "Read-only ViberQC MCP server for repository inspection and scan-session preparation",
5
+ "license": "Apache-2.0",
6
+ "keywords": [
7
+ "mcp",
8
+ "model-context-protocol",
9
+ "code-quality",
10
+ "ai-generated-code",
11
+ "viberqc"
12
+ ],
13
+ "homepage": "https://www.viberqc.com",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://gitlab.dev.jigsawgroups.work/newbiz-lab/products/ViberQC.git",
17
+ "directory": "packages/viberqc-mcp"
18
+ },
19
+ "bugs": {
20
+ "url": "https://gitlab.dev.jigsawgroups.work/newbiz-lab/products/ViberQC/-/issues"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public",
24
+ "registry": "https://registry.npmjs.org/",
25
+ "tag": "next"
26
+ },
27
+ "type": "module",
28
+ "engines": {
29
+ "node": ">=22.14.0"
30
+ },
31
+ "bin": {
32
+ "viberqc-mcp-server": "dist/index.js"
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "scripts": {
40
+ "clean": "node --input-type=module -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\"",
41
+ "build": "npm run clean && tsc",
42
+ "test": "npm run build && vitest run",
43
+ "start": "node dist/index.js"
44
+ },
45
+ "dependencies": {
46
+ "@modelcontextprotocol/sdk": "1.29.0",
47
+ "zod": "4.4.3"
48
+ },
49
+ "devDependencies": {
50
+ "@emnapi/core": "1.11.3",
51
+ "@emnapi/runtime": "1.11.3",
52
+ "@types/node": "22.20.0",
53
+ "typescript": "5.9.3",
54
+ "vitest": "4.1.10"
55
+ },
56
+ "overrides": {
57
+ "@hono/node-server": "2.0.12",
58
+ "fast-uri": "3.1.4"
59
+ }
60
+ }