Skip to content

Commit 854a891

Browse files
author
StackMemory Bot (CLI)
committed
feat(greptile): add optional Greptile MCP integration for AI code review
Proxy Greptile's MCP endpoint through StackMemory's MCP server, gated behind GREPTILE_API_KEY env var. Provides 7 tools: PR comments (with actionable count for auto-fix), PR details, list PRs, trigger review, search/create coding patterns, and status check.
1 parent 8220ee4 commit 854a891

8 files changed

Lines changed: 1354 additions & 0 deletions

File tree

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/**
2+
* Tests for Greptile MCP Client
3+
*/
4+
5+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
6+
import { GreptileClient, GreptileClientError } from '../client.js';
7+
8+
// Use vi.hoisted so refs are available in hoisted vi.mock factories
9+
const { mockConnect, mockCallTool, mockClose } = vi.hoisted(() => ({
10+
mockConnect: vi.fn(),
11+
mockCallTool: vi.fn(),
12+
mockClose: vi.fn(),
13+
}));
14+
15+
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
16+
Client: class MockClient {
17+
connect = mockConnect;
18+
callTool = mockCallTool;
19+
},
20+
}));
21+
22+
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({
23+
StreamableHTTPClientTransport: class MockTransport {
24+
close = mockClose;
25+
onclose: (() => void) | null = null;
26+
constructor() {
27+
// no-op
28+
}
29+
},
30+
}));
31+
32+
describe('GreptileClient', () => {
33+
beforeEach(() => {
34+
vi.clearAllMocks();
35+
mockConnect.mockResolvedValue(undefined);
36+
});
37+
38+
afterEach(() => {
39+
vi.restoreAllMocks();
40+
});
41+
42+
describe('constructor', () => {
43+
it('should throw when disabled (no API key)', () => {
44+
expect(() => new GreptileClient({ enabled: false, apiKey: '' })).toThrow(
45+
GreptileClientError
46+
);
47+
});
48+
49+
it('should throw with DISABLED code', () => {
50+
try {
51+
new GreptileClient({ enabled: false, apiKey: '' });
52+
} catch (error) {
53+
expect(error).toBeInstanceOf(GreptileClientError);
54+
expect((error as GreptileClientError).code).toBe('DISABLED');
55+
}
56+
});
57+
58+
it('should create client when enabled with API key', () => {
59+
const client = new GreptileClient({
60+
enabled: true,
61+
apiKey: 'test-key',
62+
mcpEndpoint: 'https://api.greptile.com/mcp',
63+
});
64+
expect(client).toBeDefined();
65+
});
66+
});
67+
68+
describe('callTool', () => {
69+
let client: GreptileClient;
70+
71+
beforeEach(() => {
72+
client = new GreptileClient({
73+
enabled: true,
74+
apiKey: 'test-key',
75+
mcpEndpoint: 'https://api.greptile.com/mcp',
76+
});
77+
});
78+
79+
it('should connect lazily on first call', async () => {
80+
mockCallTool.mockResolvedValue({
81+
content: [{ type: 'text', text: '{"ok":true}' }],
82+
});
83+
84+
await client.callTool('list_pull_requests', { limit: 1 });
85+
86+
expect(mockConnect).toHaveBeenCalledTimes(1);
87+
});
88+
89+
it('should not reconnect on subsequent calls', async () => {
90+
mockCallTool.mockResolvedValue({
91+
content: [{ type: 'text', text: '{}' }],
92+
});
93+
94+
await client.callTool('list_pull_requests', {});
95+
await client.callTool('list_pull_requests', {});
96+
97+
expect(mockConnect).toHaveBeenCalledTimes(1);
98+
});
99+
100+
it('should parse JSON text content', async () => {
101+
mockCallTool.mockResolvedValue({
102+
content: [{ type: 'text', text: '{"prNumber":42,"title":"Test PR"}' }],
103+
});
104+
105+
const result = await client.callTool('get_merge_request', {
106+
prNumber: 42,
107+
});
108+
109+
expect(result).toEqual({ prNumber: 42, title: 'Test PR' });
110+
});
111+
112+
it('should return raw text when not valid JSON', async () => {
113+
mockCallTool.mockResolvedValue({
114+
content: [{ type: 'text', text: 'Review triggered successfully' }],
115+
});
116+
117+
const result = await client.callTool('trigger_code_review', {});
118+
119+
expect(result).toBe('Review triggered successfully');
120+
});
121+
122+
it('should return full result when no text content', async () => {
123+
const rawResult = { content: [{ type: 'image', data: 'abc' }] };
124+
mockCallTool.mockResolvedValue(rawResult);
125+
126+
const result = await client.callTool('some_tool', {});
127+
128+
expect(result).toEqual(rawResult);
129+
});
130+
131+
it('should pass tool name and args to MCP client', async () => {
132+
mockCallTool.mockResolvedValue({ content: [] });
133+
134+
await client.callTool('list_merge_request_comments', {
135+
name: 'owner/repo',
136+
remote: 'github',
137+
prNumber: 10,
138+
});
139+
140+
expect(mockCallTool).toHaveBeenCalledWith({
141+
name: 'list_merge_request_comments',
142+
arguments: {
143+
name: 'owner/repo',
144+
remote: 'github',
145+
prNumber: 10,
146+
},
147+
});
148+
});
149+
150+
it('should propagate connection errors', async () => {
151+
mockConnect.mockRejectedValue(new Error('Connection refused'));
152+
153+
await expect(client.callTool('list_pull_requests', {})).rejects.toThrow(
154+
'Connection refused'
155+
);
156+
});
157+
});
158+
159+
describe('disconnect', () => {
160+
it('should close transport', async () => {
161+
const client = new GreptileClient({
162+
enabled: true,
163+
apiKey: 'test-key',
164+
mcpEndpoint: 'https://api.greptile.com/mcp',
165+
});
166+
167+
mockCallTool.mockResolvedValue({ content: [] });
168+
await client.callTool('test', {});
169+
170+
await client.disconnect();
171+
172+
expect(mockClose).toHaveBeenCalled();
173+
});
174+
175+
it('should handle disconnect when not connected', async () => {
176+
const client = new GreptileClient({
177+
enabled: true,
178+
apiKey: 'test-key',
179+
mcpEndpoint: 'https://api.greptile.com/mcp',
180+
});
181+
182+
// Should not throw
183+
await client.disconnect();
184+
});
185+
});
186+
});
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* Greptile MCP Client
3+
* Wraps @modelcontextprotocol/sdk Client + StreamableHTTPClientTransport
4+
* to proxy tool calls to the Greptile MCP endpoint.
5+
*/
6+
7+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
8+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
9+
import type { GreptileIntegrationConfig } from './config.js';
10+
import { DEFAULT_GREPTILE_CONFIG } from './config.js';
11+
12+
export class GreptileClientError extends Error {
13+
constructor(
14+
message: string,
15+
public readonly code?: string
16+
) {
17+
super(message);
18+
this.name = 'GreptileClientError';
19+
}
20+
}
21+
22+
export class GreptileClient {
23+
private readonly config: GreptileIntegrationConfig;
24+
private client: Client | null = null;
25+
private transport: StreamableHTTPClientTransport | null = null;
26+
private connecting: Promise<void> | null = null;
27+
28+
constructor(config: Partial<GreptileIntegrationConfig> = {}) {
29+
this.config = { ...DEFAULT_GREPTILE_CONFIG, ...config };
30+
31+
if (!this.config.enabled || !this.config.apiKey) {
32+
throw new GreptileClientError(
33+
'Greptile integration disabled (GREPTILE_API_KEY not set)',
34+
'DISABLED'
35+
);
36+
}
37+
}
38+
39+
private async ensureConnected(): Promise<Client> {
40+
if (this.client) return this.client;
41+
42+
// Deduplicate concurrent connection attempts
43+
if (this.connecting) {
44+
await this.connecting;
45+
return this.client!;
46+
}
47+
48+
this.connecting = this.connect();
49+
try {
50+
await this.connecting;
51+
return this.client!;
52+
} finally {
53+
this.connecting = null;
54+
}
55+
}
56+
57+
private async connect(): Promise<void> {
58+
const transport = new StreamableHTTPClientTransport(
59+
new URL(this.config.mcpEndpoint),
60+
{
61+
requestInit: {
62+
headers: {
63+
Authorization: `Bearer ${this.config.apiKey}`,
64+
},
65+
},
66+
reconnectionOptions: {
67+
maxRetries: this.config.maxRetries,
68+
initialReconnectionDelay: 1000,
69+
reconnectionDelayGrowFactor: 1.5,
70+
maxReconnectionDelay: 10000,
71+
},
72+
}
73+
);
74+
75+
const client = new Client(
76+
{ name: 'stackmemory-greptile', version: '1.0.0' },
77+
{ capabilities: {} }
78+
);
79+
80+
transport.onclose = () => {
81+
this.client = null;
82+
this.transport = null;
83+
};
84+
85+
await client.connect(transport);
86+
87+
this.client = client;
88+
this.transport = transport;
89+
}
90+
91+
async callTool(
92+
name: string,
93+
args: Record<string, unknown> = {}
94+
): Promise<unknown> {
95+
const client = await this.ensureConnected();
96+
97+
const result = await client.callTool({ name, arguments: args });
98+
99+
// Extract text content from MCP result
100+
if (result.content && Array.isArray(result.content)) {
101+
const textParts = result.content
102+
.filter(
103+
(c: { type: string; text?: string }) =>
104+
c.type === 'text' && typeof c.text === 'string'
105+
)
106+
.map((c: { type: string; text?: string }) => c.text!);
107+
108+
if (textParts.length === 0) return result;
109+
110+
const combined = textParts.join('\n');
111+
try {
112+
return JSON.parse(combined);
113+
} catch {
114+
return combined;
115+
}
116+
}
117+
118+
return result;
119+
}
120+
121+
async disconnect(): Promise<void> {
122+
if (this.transport) {
123+
await this.transport.close();
124+
}
125+
this.client = null;
126+
this.transport = null;
127+
this.connecting = null;
128+
}
129+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* Greptile Integration Configuration
3+
* Environment-driven config for Greptile MCP proxy
4+
*/
5+
6+
export interface GreptileIntegrationConfig {
7+
enabled: boolean;
8+
mcpEndpoint: string;
9+
apiKey: string;
10+
timeoutMs: number;
11+
maxRetries: number;
12+
}
13+
14+
export const DEFAULT_GREPTILE_CONFIG: GreptileIntegrationConfig = {
15+
enabled: !!process.env.GREPTILE_API_KEY,
16+
mcpEndpoint:
17+
process.env.GREPTILE_MCP_ENDPOINT || 'https://api.greptile.com/mcp',
18+
apiKey: process.env.GREPTILE_API_KEY || '',
19+
timeoutMs: 15000,
20+
maxRetries: 2,
21+
};

src/integrations/greptile/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Greptile Integration
3+
* AI-powered code review for StackMemory
4+
*/
5+
6+
export type {
7+
GreptileRepoRef,
8+
GreptileReviewComment,
9+
GreptileCustomContext,
10+
GreptileMergeRequest,
11+
GreptileCodeReview,
12+
GreptileStatus,
13+
} from './types.js';
14+
15+
export type { GreptileIntegrationConfig } from './config.js';
16+
export { DEFAULT_GREPTILE_CONFIG } from './config.js';
17+
18+
export { GreptileClient, GreptileClientError } from './client.js';

0 commit comments

Comments
 (0)