second-opinion-mcp 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 (51) hide show
  1. package/README.md +323 -0
  2. package/dist/config.d.ts +31 -0
  3. package/dist/config.js +84 -0
  4. package/dist/context/bundler.d.ts +51 -0
  5. package/dist/context/bundler.js +481 -0
  6. package/dist/context/bundler.test.d.ts +1 -0
  7. package/dist/context/bundler.test.js +275 -0
  8. package/dist/context/git.d.ts +21 -0
  9. package/dist/context/git.js +102 -0
  10. package/dist/context/imports.d.ts +43 -0
  11. package/dist/context/imports.js +197 -0
  12. package/dist/context/imports.test.d.ts +1 -0
  13. package/dist/context/imports.test.js +147 -0
  14. package/dist/context/index.d.ts +6 -0
  15. package/dist/context/index.js +6 -0
  16. package/dist/context/session.d.ts +35 -0
  17. package/dist/context/session.js +317 -0
  18. package/dist/context/tests.d.ts +13 -0
  19. package/dist/context/tests.js +83 -0
  20. package/dist/context/types.d.ts +16 -0
  21. package/dist/context/types.js +100 -0
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.js +6 -0
  24. package/dist/output/writer.d.ts +34 -0
  25. package/dist/output/writer.js +162 -0
  26. package/dist/output/writer.test.d.ts +1 -0
  27. package/dist/output/writer.test.js +175 -0
  28. package/dist/providers/base.d.ts +24 -0
  29. package/dist/providers/base.js +77 -0
  30. package/dist/providers/base.test.d.ts +1 -0
  31. package/dist/providers/base.test.js +91 -0
  32. package/dist/providers/gemini.d.ts +8 -0
  33. package/dist/providers/gemini.js +43 -0
  34. package/dist/providers/index.d.ts +8 -0
  35. package/dist/providers/index.js +31 -0
  36. package/dist/providers/openai.d.ts +8 -0
  37. package/dist/providers/openai.js +39 -0
  38. package/dist/server.d.ts +3 -0
  39. package/dist/server.js +181 -0
  40. package/dist/test-utils.d.ts +71 -0
  41. package/dist/test-utils.js +136 -0
  42. package/dist/tools/review.d.ts +76 -0
  43. package/dist/tools/review.js +199 -0
  44. package/dist/utils/index.d.ts +1 -0
  45. package/dist/utils/index.js +1 -0
  46. package/dist/utils/tokens.d.ts +26 -0
  47. package/dist/utils/tokens.js +27 -0
  48. package/package.json +61 -0
  49. package/scripts/install-config.js +51 -0
  50. package/second-opinion.skill.md +34 -0
  51. package/templates/second-opinion.md +54 -0
@@ -0,0 +1,199 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { z } from "zod";
4
+ import { loadConfig, loadReviewInstructions } from "../config.js";
5
+ import { bundleContext, formatBundleAsMarkdown, } from "../context/index.js";
6
+ import { isWithinProject } from "../context/imports.js";
7
+ import { createProvider } from "../providers/index.js";
8
+ import { writeReview, writeEgressManifest, deriveSessionName, } from "../output/writer.js";
9
+ /**
10
+ * Validate that a project path is safe to use
11
+ */
12
+ function validateProjectPath(projectPath) {
13
+ // Must be absolute
14
+ if (!path.isAbsolute(projectPath)) {
15
+ throw new Error(`projectPath must be absolute, got: ${projectPath}`);
16
+ }
17
+ // Normalize and check for traversal
18
+ const normalized = path.normalize(projectPath);
19
+ if (normalized !== projectPath && projectPath.includes("..")) {
20
+ throw new Error(`projectPath contains path traversal: ${projectPath}`);
21
+ }
22
+ // Must exist
23
+ if (!fs.existsSync(normalized)) {
24
+ throw new Error(`projectPath does not exist: ${normalized}`);
25
+ }
26
+ // Must be a directory
27
+ const stat = fs.statSync(normalized);
28
+ if (!stat.isDirectory()) {
29
+ throw new Error(`projectPath is not a directory: ${normalized}`);
30
+ }
31
+ }
32
+ export const SecondOpinionInputSchema = z.object({
33
+ // Required
34
+ provider: z.enum(["gemini", "openai"]).describe("Which LLM to use for the review"),
35
+ projectPath: z.string().describe("Absolute path to the project being reviewed"),
36
+ // Task specification
37
+ task: z
38
+ .string()
39
+ .optional()
40
+ .describe("The task or prompt for the LLM to accomplish. When omitted, defaults to code review."),
41
+ // Context options
42
+ sessionId: z
43
+ .string()
44
+ .optional()
45
+ .describe("Claude Code session ID (defaults to most recent)"),
46
+ includeFiles: z
47
+ .array(z.string())
48
+ .optional()
49
+ .describe("Additional files or folders to include (supports ~ and relative paths)"),
50
+ allowExternalFiles: z
51
+ .boolean()
52
+ .default(false)
53
+ .describe("Allow including files outside the project directory. Required when includeFiles contains paths outside the project. Use with caution as these files will be sent to the external LLM."),
54
+ includeConversation: z
55
+ .boolean()
56
+ .default(true)
57
+ .describe("Include conversation context from Claude session"),
58
+ // Smart context options
59
+ includeDependencies: z
60
+ .boolean()
61
+ .default(true)
62
+ .describe("Include files imported by modified files"),
63
+ includeDependents: z
64
+ .boolean()
65
+ .default(true)
66
+ .describe("Include files that import modified files"),
67
+ includeTests: z
68
+ .boolean()
69
+ .default(true)
70
+ .describe("Include corresponding test files"),
71
+ includeTypes: z
72
+ .boolean()
73
+ .default(true)
74
+ .describe("Include referenced type definitions"),
75
+ maxTokens: z
76
+ .number()
77
+ .default(100000)
78
+ .describe("Maximum tokens for context"),
79
+ // Output options
80
+ sessionName: z
81
+ .string()
82
+ .optional()
83
+ .describe("Name for this output (used in filename)"),
84
+ customPrompt: z
85
+ .string()
86
+ .optional()
87
+ .describe("Additional instructions (deprecated: use task instead)"),
88
+ focusAreas: z
89
+ .array(z.string())
90
+ .optional()
91
+ .describe("Specific areas to focus on (for code reviews)"),
92
+ dryRun: z
93
+ .boolean()
94
+ .default(false)
95
+ .describe("If true, return a preview of what would be sent without calling the external API. Use this for confirmation before sending files to external providers."),
96
+ });
97
+ /**
98
+ * Build egress summary from bundle, categorizing files as project vs external
99
+ */
100
+ function buildEgressSummary(bundle, projectPath, provider) {
101
+ const projectFilePaths = [];
102
+ const externalFilePaths = [];
103
+ for (const file of bundle.files) {
104
+ if (isWithinProject(file.path, projectPath)) {
105
+ projectFilePaths.push(file.path);
106
+ }
107
+ else {
108
+ externalFilePaths.push(file.path);
109
+ }
110
+ }
111
+ // Get unique parent directories of external files
112
+ const externalLocations = [
113
+ ...new Set(externalFilePaths.map((p) => path.dirname(p))),
114
+ ];
115
+ return {
116
+ projectFilesSent: projectFilePaths.length,
117
+ projectFilePaths,
118
+ externalFilesSent: externalFilePaths.length,
119
+ externalFilePaths,
120
+ externalLocations,
121
+ blockedFiles: bundle.omittedFiles.map((f) => ({
122
+ path: f.path,
123
+ reason: f.reason,
124
+ })),
125
+ provider,
126
+ };
127
+ }
128
+ export async function executeReview(input) {
129
+ // Validate project path before proceeding
130
+ validateProjectPath(input.projectPath);
131
+ const config = loadConfig();
132
+ // 1. Bundle the context
133
+ const bundle = await bundleContext({
134
+ projectPath: input.projectPath,
135
+ sessionId: input.sessionId,
136
+ includeFiles: input.includeFiles,
137
+ allowExternalFiles: input.allowExternalFiles,
138
+ includeConversation: input.includeConversation,
139
+ includeDependencies: input.includeDependencies,
140
+ includeDependents: input.includeDependents,
141
+ includeTests: input.includeTests,
142
+ includeTypes: input.includeTypes,
143
+ maxTokens: input.maxTokens,
144
+ });
145
+ // Build egress summary (used for both dry run and actual execution)
146
+ const summary = buildEgressSummary(bundle, input.projectPath, input.provider);
147
+ // 2. If dry run, return preview without calling external API
148
+ if (input.dryRun) {
149
+ return {
150
+ dryRun: true,
151
+ provider: input.provider,
152
+ summary,
153
+ totalTokens: bundle.totalTokens,
154
+ };
155
+ }
156
+ // 3. Format as markdown
157
+ const contextMarkdown = formatBundleAsMarkdown(bundle, input.projectPath);
158
+ // 4. Load review instructions
159
+ const instructions = loadReviewInstructions(input.projectPath);
160
+ // 5. Create provider and execute task
161
+ const provider = createProvider(input.provider, config);
162
+ const response = await provider.review({
163
+ instructions,
164
+ context: contextMarkdown,
165
+ task: input.task,
166
+ focusAreas: input.focusAreas,
167
+ customPrompt: input.customPrompt,
168
+ });
169
+ // 6. Derive session name if not provided
170
+ const sessionName = input.sessionName ||
171
+ deriveSessionName(bundle.conversationContext, "code-review");
172
+ // 7. Write the output files
173
+ const timestamp = new Date().toISOString();
174
+ const metadata = {
175
+ sessionName,
176
+ provider: input.provider,
177
+ model: response.model,
178
+ timestamp,
179
+ filesReviewed: bundle.files.map((f) => f.path),
180
+ tokensUsed: response.tokensUsed,
181
+ task: input.task,
182
+ };
183
+ const reviewFile = writeReview(input.projectPath, config.reviewsDir, metadata, response.review);
184
+ // 8. Write egress manifest for audit trail
185
+ const egressManifestFile = writeEgressManifest(input.projectPath, config.reviewsDir, metadata, summary);
186
+ return {
187
+ dryRun: false,
188
+ review: response.review,
189
+ reviewFile,
190
+ egressManifestFile,
191
+ provider: input.provider,
192
+ model: response.model,
193
+ tokensUsed: response.tokensUsed,
194
+ timestamp,
195
+ filesReviewed: bundle.files.length,
196
+ contextTokens: bundle.totalTokens,
197
+ summary,
198
+ };
199
+ }
@@ -0,0 +1 @@
1
+ export { estimateTokens, BUDGET_ALLOCATION, type BudgetCategory } from "./tokens.js";
@@ -0,0 +1 @@
1
+ export { estimateTokens, BUDGET_ALLOCATION } from "./tokens.js";
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Token estimation utilities for context budgeting
3
+ */
4
+ /**
5
+ * Rough token estimation (4 chars per token is a reasonable approximation for code)
6
+ *
7
+ * This is intentionally simple - for budget estimation we don't need tiktoken's
8
+ * precision, just a ballpark to avoid sending too much context.
9
+ */
10
+ export declare function estimateTokens(text: string): number;
11
+ /**
12
+ * Budget allocation by category (explicit files get priority, then session, etc.)
13
+ *
14
+ * These percentages determine how the token budget is divided when multiple
15
+ * categories of files compete for space.
16
+ */
17
+ export declare const BUDGET_ALLOCATION: {
18
+ readonly explicit: 0.15;
19
+ readonly session: 0.3;
20
+ readonly git: 0.1;
21
+ readonly dependency: 0.15;
22
+ readonly dependent: 0.15;
23
+ readonly test: 0.1;
24
+ readonly type: 0.05;
25
+ };
26
+ export type BudgetCategory = keyof typeof BUDGET_ALLOCATION;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Token estimation utilities for context budgeting
3
+ */
4
+ /**
5
+ * Rough token estimation (4 chars per token is a reasonable approximation for code)
6
+ *
7
+ * This is intentionally simple - for budget estimation we don't need tiktoken's
8
+ * precision, just a ballpark to avoid sending too much context.
9
+ */
10
+ export function estimateTokens(text) {
11
+ return Math.ceil(text.length / 4);
12
+ }
13
+ /**
14
+ * Budget allocation by category (explicit files get priority, then session, etc.)
15
+ *
16
+ * These percentages determine how the token budget is divided when multiple
17
+ * categories of files compete for space.
18
+ */
19
+ export const BUDGET_ALLOCATION = {
20
+ explicit: 0.15,
21
+ session: 0.3,
22
+ git: 0.1,
23
+ dependency: 0.15,
24
+ dependent: 0.15,
25
+ test: 0.1,
26
+ type: 0.05,
27
+ };
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "second-opinion-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for getting code reviews from Gemini/GPT in Claude Code",
5
+ "keywords": [
6
+ "mcp",
7
+ "claude",
8
+ "claude-code",
9
+ "code-review",
10
+ "gemini",
11
+ "openai",
12
+ "gpt"
13
+ ],
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/noelweichbrodt/second-opinion.git"
18
+ },
19
+ "homepage": "https://github.com/noelweichbrodt/second-opinion#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/noelweichbrodt/second-opinion/issues"
22
+ },
23
+ "type": "module",
24
+ "main": "dist/index.js",
25
+ "bin": {
26
+ "second-opinion-mcp": "dist/index.js"
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "templates",
31
+ "scripts",
32
+ "second-opinion.skill.md"
33
+ ],
34
+ "engines": {
35
+ "node": ">=18"
36
+ },
37
+ "scripts": {
38
+ "build": "tsc",
39
+ "start": "node dist/index.js",
40
+ "dev": "tsx src/index.ts",
41
+ "test": "vitest run",
42
+ "test:watch": "vitest",
43
+ "prepublishOnly": "npm run build",
44
+ "postinstall": "node scripts/install-config.js",
45
+ "install-config": "node scripts/install-config.js"
46
+ },
47
+ "dependencies": {
48
+ "@google/generative-ai": "^0.21.0",
49
+ "@modelcontextprotocol/sdk": "^1.0.0",
50
+ "glob": "^11.0.0",
51
+ "openai": "^4.77.0",
52
+ "zod": "^3.24.1"
53
+ },
54
+ "devDependencies": {
55
+ "@types/node": "^22.10.5",
56
+ "@vitest/coverage-v8": "^4.0.18",
57
+ "tsx": "^4.19.2",
58
+ "typescript": "^5.7.2",
59
+ "vitest": "^4.0.18"
60
+ }
61
+ }
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ import * as os from "os";
6
+ import { fileURLToPath } from "url";
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const projectRoot = path.join(__dirname, "..");
10
+
11
+ // Paths for review instructions template
12
+ const configDir = path.join(os.homedir(), ".config", "second-opinion");
13
+ const templatePath = path.join(projectRoot, "templates", "second-opinion.md");
14
+ const targetPath = path.join(configDir, "second-opinion.md");
15
+
16
+ // Paths for slash command
17
+ const claudeCommandsDir = path.join(os.homedir(), ".claude", "commands");
18
+ const skillPath = path.join(projectRoot, "second-opinion.skill.md");
19
+ const commandTargetPath = path.join(claudeCommandsDir, "second-opinion.md");
20
+
21
+ // Create config directory if it doesn't exist
22
+ if (!fs.existsSync(configDir)) {
23
+ fs.mkdirSync(configDir, { recursive: true });
24
+ console.log(`Created config directory: ${configDir}`);
25
+ }
26
+
27
+ // Copy template if target doesn't exist
28
+ if (!fs.existsSync(targetPath)) {
29
+ fs.copyFileSync(templatePath, targetPath);
30
+ console.log(`Installed default review instructions to: ${targetPath}`);
31
+ } else {
32
+ console.log(`Review instructions already exist at: ${targetPath}`);
33
+ console.log("Skipping to preserve your customizations.");
34
+ }
35
+
36
+ // Install slash command to ~/.claude/commands/
37
+ if (!fs.existsSync(claudeCommandsDir)) {
38
+ fs.mkdirSync(claudeCommandsDir, { recursive: true });
39
+ console.log(`Created Claude commands directory: ${claudeCommandsDir}`);
40
+ }
41
+
42
+ if (fs.existsSync(skillPath)) {
43
+ fs.copyFileSync(skillPath, commandTargetPath);
44
+ console.log(`Installed /second-opinion command to: ${commandTargetPath}`);
45
+ } else {
46
+ console.log(`Warning: Skill file not found at ${skillPath}`);
47
+ }
48
+
49
+ console.log("\nSetup complete! Make sure to set your API keys:");
50
+ console.log(" export GEMINI_API_KEY=your-key # For Gemini");
51
+ console.log(" export OPENAI_API_KEY=your-key # For GPT");
@@ -0,0 +1,34 @@
1
+ ---
2
+ name: second-opinion
3
+ description: Get feedback from Gemini or GPT on your current work
4
+ user-invocable: true
5
+ ---
6
+
7
+ # Second Opinion Skill
8
+
9
+ Get a code review or custom feedback from an external LLM (Gemini or GPT).
10
+
11
+ ## Usage
12
+
13
+ - `/second-opinion` - Default code review from Gemini
14
+ - `/second-opinion [task]` - Custom task for Gemini
15
+ - `/second-opinion openai [task]` - Use GPT instead
16
+ - `/second-opinion gemini [task]` - Explicitly use Gemini
17
+
18
+ ## Instructions
19
+
20
+ When invoked, parse the arguments:
21
+
22
+ 1. Check if first word is a provider (`gemini` or `openai`). If not, default to `gemini`.
23
+ 2. Remaining text becomes the custom task (if any).
24
+ 3. Derive a session name from the work done in this conversation (e.g., "add-auth-flow", "fix-login-bug").
25
+
26
+ Call the `mcp__second-opinion__second_opinion` tool with:
27
+ - `provider`: "gemini" or "openai"
28
+ - `projectPath`: The current working directory (absolute path)
29
+ - `sessionName`: Descriptive name based on the work done
30
+ - `customPrompt`: The user's task text (if provided)
31
+
32
+ After the review completes, report:
33
+ - Path to the output file
34
+ - Brief summary of key findings
@@ -0,0 +1,54 @@
1
+ # Code Review Instructions
2
+
3
+ You are a code reviewer providing a second opinion on code changes made during a Claude Code session.
4
+
5
+ ## Your Role
6
+
7
+ - Review the code changes objectively and thoroughly
8
+ - Identify potential issues, bugs, security vulnerabilities, or improvements
9
+ - Be constructive and specific in your feedback
10
+ - Consider the conversation context to understand what was requested
11
+
12
+ ## Review Focus
13
+
14
+ 1. **Correctness**: Does the code do what it's supposed to do?
15
+ 2. **Security**: Are there any security vulnerabilities (injection, XSS, auth issues, etc.)?
16
+ 3. **Performance**: Are there obvious performance issues or inefficiencies?
17
+ 4. **Maintainability**: Is the code clear, well-organized, and easy to understand?
18
+ 5. **Error Handling**: Are errors handled appropriately?
19
+ 6. **Edge Cases**: Are edge cases considered?
20
+
21
+ ## Output Format
22
+
23
+ Structure your review as follows:
24
+
25
+ ### Summary
26
+ 2-3 sentences summarizing the changes and your overall assessment.
27
+
28
+ ### Critical Issues
29
+ Issues that should be fixed before merging (if any):
30
+ - Issue description
31
+ - Why it matters
32
+ - Suggested fix
33
+
34
+ ### Suggestions
35
+ Improvements that would be nice to have:
36
+ - What could be improved
37
+ - Why it would help
38
+
39
+ ### Questions
40
+ Things that are unclear or might need clarification:
41
+ - Question about intent or implementation
42
+
43
+ ### What's Done Well
44
+ Positive aspects of the implementation:
45
+ - Good practices observed
46
+ - Clever solutions
47
+
48
+ ## Guidelines
49
+
50
+ - Be specific: Reference file names and line numbers when possible
51
+ - Be constructive: Don't just point out problems, suggest solutions
52
+ - Be proportionate: Don't nitpick minor style issues if there are bigger concerns
53
+ - Consider context: The conversation shows what was asked for - review against those requirements
54
+ - Be honest: If the code looks good, say so