@splicr/mcp-server 0.11.1 → 0.12.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.
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { grepKnowledgeSchema, handleGrepKnowledge } from './tools/grep-knowledge
15
15
  import { exploreKnowledgeSchema, handleExploreKnowledge } from './tools/explore-knowledge.js';
16
16
  import { getDecisionsSchema, handleGetDecisions } from './tools/get-decisions.js';
17
17
  import { getTeamStatusSchema, handleGetTeamStatus } from './tools/get-team-status.js';
18
+ import { reviewCodeSchema, handleReviewCode } from './tools/review-code.js';
18
19
  import { completeSession } from './lib/api-client.js';
19
20
  // Prevent unhandled errors from crashing the MCP server
20
21
  process.on('uncaughtException', (err) => {
@@ -44,6 +45,9 @@ CONTRACTS (you must follow these):
44
45
  3. Only fall back to web search or training data if Splicr returns no results or results don't cover the question.
45
46
  4. At the END of significant sessions, call save_from_agent with decisions, discoveries, or workarounds you made. Include the context field for attribution.
46
47
 
48
+ QUALITY GATE:
49
+ 5. Before committing or opening a PR, call review_code to check your changes against the team's active patterns. Fix any violations before proceeding.
50
+
47
51
  FAILURE MODES:
48
52
  - Splicr returns no results → proceed with web search or training data
49
53
  - Splicr API is unreachable → proceed without it, don't block the session
@@ -65,6 +69,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
65
69
  exploreKnowledgeSchema,
66
70
  getDecisionsSchema,
67
71
  getTeamStatusSchema,
72
+ reviewCodeSchema,
68
73
  ],
69
74
  }));
70
75
  // Handle tool calls with per-tool timeout
@@ -85,6 +90,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
85
90
  explore_knowledge: handleExploreKnowledge,
86
91
  get_decisions: handleGetDecisions,
87
92
  get_team_status: handleGetTeamStatus,
93
+ review_code: handleReviewCode,
88
94
  }[name];
89
95
  if (!handler) {
90
96
  return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
@@ -146,7 +146,4 @@ export declare function getDecisions(params: {
146
146
  }): Promise<{
147
147
  results: any[];
148
148
  }>;
149
- export declare function getTeamStatus(params: {
150
- project?: string;
151
- }): Promise<any>;
152
149
  export { API_URL };
@@ -104,8 +104,4 @@ export async function getDecisions(params) {
104
104
  const data = await apiRequest('POST', '/mcp/decisions', params);
105
105
  return { results: data.results ?? [] };
106
106
  }
107
- export async function getTeamStatus(params) {
108
- const query = params.project ? `?project=${encodeURIComponent(params.project)}` : '';
109
- return await apiRequest('GET', `/mcp/team-status${query}`);
110
- }
111
107
  export { API_URL };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Local GitHub Status - uses `gh` CLI instead of stored token.
3
+ * Zero onboarding friction: if the user has `gh auth login` done, this just works.
4
+ */
5
+ export interface TeamStatus {
6
+ repo: string;
7
+ open_prs: Array<{
8
+ number: number;
9
+ title: string;
10
+ author: string;
11
+ branch: string;
12
+ updated: string;
13
+ draft: boolean;
14
+ }>;
15
+ recent_merges: Array<{
16
+ title: string;
17
+ author: string;
18
+ merged_at: string;
19
+ }>;
20
+ active_branches: Array<{
21
+ name: string;
22
+ }>;
23
+ error?: string;
24
+ }
25
+ export declare function getLocalGitHubStatus(cwd: string): Promise<TeamStatus>;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Local GitHub Status - uses `gh` CLI instead of stored token.
3
+ * Zero onboarding friction: if the user has `gh auth login` done, this just works.
4
+ */
5
+ import { execSync } from 'child_process';
6
+ import { getGitRemoteUrl, normalizeGitUrl } from './project-detector.js';
7
+ /** Check if `gh` CLI is available and authenticated */
8
+ function isGhAvailable() {
9
+ try {
10
+ execSync('gh auth status', { encoding: 'utf-8', stdio: 'pipe' });
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ /** Extract owner/repo from a git remote URL */
18
+ function extractRepoFullName(cwd) {
19
+ const remote = getGitRemoteUrl(cwd);
20
+ if (!remote)
21
+ return null;
22
+ const normalized = normalizeGitUrl(remote);
23
+ // https://github.com/owner/repo -> owner/repo
24
+ const match = normalized.match(/github\.com\/([^/]+\/[^/]+)/);
25
+ return match ? match[1] : null;
26
+ }
27
+ /** Run a gh api command and parse JSON result */
28
+ function ghApi(endpoint) {
29
+ try {
30
+ const result = execSync(`gh api "${endpoint}" --paginate`, {
31
+ encoding: 'utf-8',
32
+ stdio: ['pipe', 'pipe', 'pipe'],
33
+ timeout: 10000,
34
+ });
35
+ return JSON.parse(result);
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ export async function getLocalGitHubStatus(cwd) {
42
+ const repoFullName = extractRepoFullName(cwd);
43
+ if (!repoFullName) {
44
+ return { repo: 'unknown', open_prs: [], recent_merges: [], active_branches: [], error: 'No GitHub remote found in this directory.' };
45
+ }
46
+ if (!isGhAvailable()) {
47
+ return { repo: repoFullName, open_prs: [], recent_merges: [], active_branches: [], error: 'GitHub CLI (gh) not authenticated. Run `gh auth login` to enable team status.' };
48
+ }
49
+ const status = {
50
+ repo: repoFullName,
51
+ open_prs: [],
52
+ recent_merges: [],
53
+ active_branches: [],
54
+ };
55
+ // Fetch open PRs
56
+ const openPRs = ghApi(`repos/${repoFullName}/pulls?state=open&sort=updated&direction=desc&per_page=10`);
57
+ if (openPRs) {
58
+ status.open_prs = openPRs.map(pr => ({
59
+ number: pr.number,
60
+ title: pr.title,
61
+ author: pr.user.login,
62
+ branch: pr.head?.ref || pr.headRefName || '',
63
+ updated: pr.updated_at || pr.updatedAt || '',
64
+ draft: pr.draft ?? pr.isDraft ?? false,
65
+ }));
66
+ }
67
+ // Fetch recently merged PRs
68
+ const mergedPRs = ghApi(`repos/${repoFullName}/pulls?state=closed&sort=updated&direction=desc&per_page=10`);
69
+ if (mergedPRs) {
70
+ const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
71
+ status.recent_merges = mergedPRs
72
+ .filter(pr => {
73
+ const mergedDate = pr.merged_at || pr.mergedAt || pr.updated_at || pr.updatedAt;
74
+ return mergedDate && new Date(mergedDate) >= sevenDaysAgo;
75
+ })
76
+ .map(pr => ({
77
+ title: pr.title,
78
+ author: pr.user.login,
79
+ merged_at: pr.merged_at || pr.mergedAt || pr.updated_at || pr.updatedAt || '',
80
+ }));
81
+ }
82
+ // Fetch branches
83
+ const branches = ghApi(`repos/${repoFullName}/branches?per_page=20`);
84
+ if (branches) {
85
+ const skipBranches = new Set(['main', 'master', 'develop', 'dev', 'staging', 'production']);
86
+ status.active_branches = branches
87
+ .filter(b => !skipBranches.has(b.name))
88
+ .slice(0, 10)
89
+ .map(b => ({ name: b.name }));
90
+ }
91
+ return status;
92
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Pattern Validator - checks git diff against active project patterns.
3
+ * Core logic reusable from MCP tool, hooks, or future GitHub Action.
4
+ */
5
+ export interface PatternViolationReport {
6
+ repo: string;
7
+ project_name: string | null;
8
+ diff_stats: {
9
+ files_changed: number;
10
+ insertions: number;
11
+ deletions: number;
12
+ };
13
+ patterns_checked: Array<{
14
+ name: string;
15
+ description: string;
16
+ }>;
17
+ diff_summary: string;
18
+ review_prompt: string;
19
+ error?: string;
20
+ }
21
+ export declare function validateAgainstPatterns(cwd: string, scope?: 'staged' | 'unstaged' | 'last-commit' | 'branch'): Promise<PatternViolationReport>;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Pattern Validator - checks git diff against active project patterns.
3
+ * Core logic reusable from MCP tool, hooks, or future GitHub Action.
4
+ */
5
+ import { execSync } from 'child_process';
6
+ import { getProjectContext } from './api-client.js';
7
+ import { detectProject } from './project-detector.js';
8
+ /** Get git diff - supports staged, unstaged, or last N commits */
9
+ function getGitDiff(cwd, scope) {
10
+ try {
11
+ const cmds = {
12
+ 'staged': 'git diff --cached',
13
+ 'unstaged': 'git diff',
14
+ 'last-commit': 'git diff HEAD~1 HEAD',
15
+ 'branch': 'git diff main...HEAD',
16
+ };
17
+ return execSync(cmds[scope], { cwd, encoding: 'utf-8', maxBuffer: 1024 * 1024 }).trim();
18
+ }
19
+ catch {
20
+ return '';
21
+ }
22
+ }
23
+ /** Get diff stats */
24
+ function getDiffStats(cwd, scope) {
25
+ try {
26
+ const cmds = {
27
+ 'staged': 'git diff --cached --shortstat',
28
+ 'unstaged': 'git diff --shortstat',
29
+ 'last-commit': 'git diff HEAD~1 HEAD --shortstat',
30
+ 'branch': 'git diff main...HEAD --shortstat',
31
+ };
32
+ const stat = execSync(cmds[scope], { cwd, encoding: 'utf-8' }).trim();
33
+ const files = stat.match(/(\d+) files? changed/)?.[1] || '0';
34
+ const ins = stat.match(/(\d+) insertions?/)?.[1] || '0';
35
+ const del = stat.match(/(\d+) deletions?/)?.[1] || '0';
36
+ return { files_changed: parseInt(files), insertions: parseInt(ins), deletions: parseInt(del) };
37
+ }
38
+ catch {
39
+ return { files_changed: 0, insertions: 0, deletions: 0 };
40
+ }
41
+ }
42
+ /** Get changed file names */
43
+ function getChangedFiles(cwd, scope) {
44
+ try {
45
+ const cmds = {
46
+ 'staged': 'git diff --cached --name-only',
47
+ 'unstaged': 'git diff --name-only',
48
+ 'last-commit': 'git diff HEAD~1 HEAD --name-only',
49
+ 'branch': 'git diff main...HEAD --name-only',
50
+ };
51
+ const result = execSync(cmds[scope], { cwd, encoding: 'utf-8' }).trim();
52
+ return result ? result.split('\n') : [];
53
+ }
54
+ catch {
55
+ return [];
56
+ }
57
+ }
58
+ /** Truncate diff to fit in context - keep most important parts */
59
+ function truncateDiff(diff, maxLines = 200) {
60
+ const lines = diff.split('\n');
61
+ if (lines.length <= maxLines)
62
+ return diff;
63
+ // Keep first maxLines with a truncation note
64
+ return lines.slice(0, maxLines).join('\n') + `\n\n... (truncated - ${lines.length - maxLines} more lines. Use a narrower scope for full diff.)`;
65
+ }
66
+ export async function validateAgainstPatterns(cwd, scope = 'staged') {
67
+ // Detect project
68
+ const detected = await detectProject(cwd).catch(() => null);
69
+ if (!detected) {
70
+ return {
71
+ repo: cwd,
72
+ project_name: null,
73
+ diff_stats: { files_changed: 0, insertions: 0, deletions: 0 },
74
+ patterns_checked: [],
75
+ diff_summary: '',
76
+ review_prompt: '',
77
+ error: 'Could not detect project. Register this project with Splicr first.',
78
+ };
79
+ }
80
+ // Fetch patterns
81
+ let patterns = [];
82
+ try {
83
+ const ctx = await getProjectContext({
84
+ project_name: detected.name,
85
+ project_id: detected.id,
86
+ limit: 1, // only need patterns
87
+ });
88
+ patterns = (ctx.patterns || []).map((p) => ({
89
+ name: p.name,
90
+ description: p.description,
91
+ }));
92
+ }
93
+ catch { /* no patterns available */ }
94
+ if (patterns.length === 0) {
95
+ return {
96
+ repo: detected.name,
97
+ project_name: detected.name,
98
+ diff_stats: { files_changed: 0, insertions: 0, deletions: 0 },
99
+ patterns_checked: [],
100
+ diff_summary: '',
101
+ review_prompt: '',
102
+ error: 'No active patterns found for this project. Patterns are established over time as agents save learnings.',
103
+ };
104
+ }
105
+ // Get diff
106
+ let diff = getGitDiff(cwd, scope);
107
+ // If staged is empty, try unstaged
108
+ if (!diff && scope === 'staged') {
109
+ diff = getGitDiff(cwd, 'unstaged');
110
+ if (!diff) {
111
+ diff = getGitDiff(cwd, 'last-commit');
112
+ if (diff)
113
+ scope = 'last-commit';
114
+ }
115
+ else {
116
+ scope = 'unstaged';
117
+ }
118
+ }
119
+ if (!diff) {
120
+ return {
121
+ repo: detected.name,
122
+ project_name: detected.name,
123
+ diff_stats: { files_changed: 0, insertions: 0, deletions: 0 },
124
+ patterns_checked: patterns,
125
+ diff_summary: '',
126
+ review_prompt: '',
127
+ error: 'No changes detected. Stage changes, make commits, or specify a scope.',
128
+ };
129
+ }
130
+ const stats = getDiffStats(cwd, scope);
131
+ const files = getChangedFiles(cwd, scope);
132
+ const truncatedDiff = truncateDiff(diff);
133
+ // Build the review prompt - the agent IS the AI reviewer
134
+ const patternList = patterns.map((p, i) => `${i + 1}. **${p.name}** — ${p.description}`).join('\n');
135
+ const reviewPrompt = `## Pattern Compliance Review
136
+
137
+ ### Active Patterns (${patterns.length}):
138
+ ${patternList}
139
+
140
+ ### Changes (${scope}, ${stats.files_changed} files, +${stats.insertions}/-${stats.deletions}):
141
+ Files: ${files.join(', ')}
142
+
143
+ ### Diff:
144
+ \`\`\`diff
145
+ ${truncatedDiff}
146
+ \`\`\`
147
+
148
+ ### Your task:
149
+ Review the diff above against each active pattern. For each pattern:
150
+ - **PASS** if the changes comply or the pattern is not relevant to these changes
151
+ - **VIOLATION** if the changes contradict the pattern - cite the specific line(s)
152
+ - **WARNING** if the changes are in a gray area
153
+
154
+ End with a summary: total violations, total warnings, and whether this is safe to merge.`;
155
+ return {
156
+ repo: detected.name,
157
+ project_name: detected.name,
158
+ diff_stats: stats,
159
+ patterns_checked: patterns,
160
+ diff_summary: `${stats.files_changed} files changed (+${stats.insertions}/-${stats.deletions}): ${files.slice(0, 5).join(', ')}${files.length > 5 ? ` +${files.length - 5} more` : ''}`,
161
+ review_prompt: reviewPrompt,
162
+ };
163
+ }
@@ -3,12 +3,7 @@ export declare const getTeamStatusSchema: {
3
3
  description: string;
4
4
  inputSchema: {
5
5
  type: "object";
6
- properties: {
7
- project: {
8
- type: "string";
9
- description: string;
10
- };
11
- };
6
+ properties: {};
12
7
  };
13
8
  };
14
9
  export declare function handleGetTeamStatus(args: Record<string, unknown>): Promise<string>;
@@ -1,5 +1,4 @@
1
- import { getTeamStatus } from '../lib/api-client.js';
2
- import { detectProject } from '../lib/project-detector.js';
1
+ import { getLocalGitHubStatus } from '../lib/github-local.js';
3
2
  import * as session from '../lib/session-state.js';
4
3
  export const getTeamStatusSchema = {
5
4
  name: 'get_team_status',
@@ -10,25 +9,14 @@ Use when:
10
9
  - Before starting work on a feature (check if someone else is already on it)
11
10
  - To understand recent changes to the codebase
12
11
 
13
- Requires GitHub integration (connected via Splicr dashboard).`,
12
+ Requires GitHub CLI (gh) to be authenticated. Run \`gh auth login\` if not set up.`,
14
13
  inputSchema: {
15
14
  type: 'object',
16
- properties: {
17
- project: { type: 'string', description: 'Project name or "auto" (default: auto)' },
18
- },
15
+ properties: {},
19
16
  },
20
17
  };
21
18
  export async function handleGetTeamStatus(args) {
22
- const projectArg = args.project || 'auto';
23
- let projectName;
24
- if (projectArg === 'auto') {
25
- const detected = await detectProject(process.cwd());
26
- projectName = detected?.name;
27
- }
28
- else {
29
- projectName = projectArg;
30
- }
31
- const data = await getTeamStatus({ project: projectName });
19
+ const data = await getLocalGitHubStatus(process.cwd());
32
20
  session.recordToolCall();
33
21
  if (data.error) {
34
22
  return data.error;
@@ -0,0 +1,15 @@
1
+ export declare const reviewCodeSchema: {
2
+ name: "review_code";
3
+ description: string;
4
+ inputSchema: {
5
+ type: "object";
6
+ properties: {
7
+ scope: {
8
+ type: "string";
9
+ enum: string[];
10
+ description: string;
11
+ };
12
+ };
13
+ };
14
+ };
15
+ export declare function handleReviewCode(args: Record<string, unknown>): Promise<string>;
@@ -0,0 +1,45 @@
1
+ import { validateAgainstPatterns } from '../lib/pattern-validator.js';
2
+ import * as session from '../lib/session-state.js';
3
+ export const reviewCodeSchema = {
4
+ name: 'review_code',
5
+ description: `Review current code changes against the team's active patterns. Checks your git diff for pattern violations before you commit or open a PR.
6
+
7
+ Use when:
8
+ - Before committing: catch violations early
9
+ - Before opening a PR: ensure compliance with team conventions
10
+ - After making changes: self-review against established patterns
11
+ - When asked to review code quality
12
+
13
+ Scopes:
14
+ - "staged" (default): checks staged changes (git diff --cached)
15
+ - "unstaged": checks working directory changes
16
+ - "last-commit": checks the most recent commit
17
+ - "branch": checks all changes since diverging from main
18
+
19
+ Returns the diff + active patterns formatted for review. You (the agent) are the reviewer - analyze each pattern against the changes and report violations.`,
20
+ inputSchema: {
21
+ type: 'object',
22
+ properties: {
23
+ scope: {
24
+ type: 'string',
25
+ enum: ['staged', 'unstaged', 'last-commit', 'branch'],
26
+ description: 'What to review: staged (default), unstaged, last-commit, or branch (all commits since main)',
27
+ },
28
+ },
29
+ },
30
+ };
31
+ export async function handleReviewCode(args) {
32
+ const scope = args.scope || 'staged';
33
+ const report = await validateAgainstPatterns(process.cwd(), scope);
34
+ session.recordToolCall();
35
+ if (report.error) {
36
+ return report.error;
37
+ }
38
+ // Header
39
+ let output = `*Pattern review for ${report.project_name}*\n`;
40
+ output += `${report.diff_summary}\n`;
41
+ output += `Checking against ${report.patterns_checked.length} active pattern(s).\n\n`;
42
+ // The review prompt — agent reads this and does the actual review
43
+ output += report.review_prompt;
44
+ return output;
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@splicr/mcp-server",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "Splicr MCP server — route what you read to what you're building",
5
5
  "type": "module",
6
6
  "bin": "./dist/cli.js",