hac-mcp 1.0.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/server.js ADDED
@@ -0,0 +1,276 @@
1
+ import express from 'express';
2
+ import { createServer } from 'http';
3
+ import { fileURLToPath } from 'url';
4
+ import { dirname, join } from 'path';
5
+ import { homedir } from 'os';
6
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
10
+ import { flexibleSearch, setHacLogger } from './hac.js';
11
+ import { listEnvironments, getEnvironment, createEnvironment, updateEnvironment, deleteEnvironment } from './storage.js';
12
+ import { getIndex } from './type-index.js';
13
+ import { registerAllTools, tools as allTools } from './tools/index.js';
14
+ import { getSession, withSession, attachLogClient, detachLogClient, getMcpLogBuffer, mcpLogSystem } from './tools/context.js';
15
+
16
+ const PORT = process.env.PORT || 18432;
17
+
18
+ // ─── HAC request log → SSE broadcast ─────────────────────────────────────────
19
+ const hacLogClients = new Set();
20
+ const hacLogBuffer = [];
21
+ setHacLogger(entry => {
22
+ hacLogBuffer.push(entry);
23
+ if (hacLogBuffer.length > 50) hacLogBuffer.shift();
24
+ const data = `data: ${JSON.stringify(entry)}\n\n`;
25
+ for (const res of hacLogClients) res.write(data);
26
+ });
27
+
28
+ // ─── MCP server factory ───────────────────────────────────────────────────────
29
+ function createMcpInstance(getClientLabel) {
30
+ const mcp = new McpServer({ name: 'hac-mcp', version: '1.0.0' }, { timeout: 60000 });
31
+ registerAllTools(mcp, getClientLabel);
32
+ return mcp;
33
+ }
34
+
35
+ let clientCounter = 0;
36
+ function clientLabel(session) {
37
+ const num = `Client #${session.clientNum}`;
38
+ const v = session.clientInfo?.version;
39
+ if (!v) return num;
40
+ return `${num} · ${v.title || v.name} ${v.version}`;
41
+ }
42
+
43
+ // ─── Express ──────────────────────────────────────────────────────────────────
44
+ const app = express();
45
+ app.use(express.json());
46
+ app.use(express.urlencoded({ extended: true }));
47
+ app.use('/static', express.static(join(__dirname, 'static')));
48
+
49
+ // Mock OAuth endpoints - auto-approve everything, no user interaction required
50
+ const BASE_URL = `http://localhost:${PORT}`;
51
+
52
+ app.get('/.well-known/oauth-authorization-server', (_req, res) => {
53
+ res.json({
54
+ issuer: BASE_URL,
55
+ authorization_endpoint: `${BASE_URL}/authorize`,
56
+ token_endpoint: `${BASE_URL}/token`,
57
+ registration_endpoint: `${BASE_URL}/register`,
58
+ response_types_supported: ['code'],
59
+ grant_types_supported: ['authorization_code'],
60
+ code_challenge_methods_supported: ['S256'],
61
+ });
62
+ });
63
+
64
+ app.post('/register', (req, res) => {
65
+ const body = req.body ?? {};
66
+ res.json({
67
+ client_id: 'mock-client',
68
+ client_secret: 'mock-secret',
69
+ client_id_issued_at: Math.floor(Date.now() / 1000),
70
+ redirect_uris: body.redirect_uris ?? [],
71
+ grant_types: body.grant_types ?? ['authorization_code'],
72
+ response_types: body.response_types ?? ['code'],
73
+ token_endpoint_auth_method: 'client_secret_basic',
74
+ });
75
+ });
76
+
77
+ app.get('/authorize', (req, res) => {
78
+ const { redirect_uri, state } = req.query;
79
+ const code = `mock-code-${Date.now()}`;
80
+ const url = new URL(redirect_uri);
81
+ url.searchParams.set('code', code);
82
+ if (state) url.searchParams.set('state', state);
83
+ res.redirect(url.toString());
84
+ });
85
+
86
+ app.post('/token', (_req, res) => {
87
+ res.json({
88
+ access_token: 'mock-access-token',
89
+ token_type: 'bearer',
90
+ expires_in: 86400,
91
+ });
92
+ });
93
+ app.get('/', (_req, res) => res.sendFile(join(__dirname, 'static', 'index.html')));
94
+
95
+ // Environments API
96
+ app.get('/api/environments', async (_req, res) => res.json(await listEnvironments()));
97
+ app.post('/api/environments', async (req, res) => {
98
+ try { res.json(await createEnvironment(req.body)); }
99
+ catch (e) { res.status(400).json({ error: e.message }); }
100
+ });
101
+ app.put('/api/environments/:id', async (req, res) => {
102
+ try { res.json(await updateEnvironment(req.params.id, req.body)); }
103
+ catch (e) { res.status(400).json({ error: e.message }); }
104
+ });
105
+ app.delete('/api/environments/:id', async (req, res) => {
106
+ try { await deleteEnvironment(req.params.id); res.json({ ok: true }); }
107
+ catch (e) { res.status(400).json({ error: e.message }); }
108
+ });
109
+
110
+ app.post('/api/environments/:id/refresh-index', async (req, res) => {
111
+ const env = await getEnvironment(req.params.id);
112
+ if (!env) return res.status(404).json({ ok: false, error: 'Environment not found' });
113
+ try {
114
+ const types = await getIndex(env.id, (query, opts) => withSession(env, s => flexibleSearch(s, query, opts)));
115
+ res.json({ ok: true, count: types.length });
116
+ } catch (e) {
117
+ res.json({ ok: false, error: e.message });
118
+ }
119
+ });
120
+
121
+ app.post('/api/test-connection', async (req, res) => {
122
+ let { url, username, password } = req.body;
123
+ if (!url || !username || !password) return res.json({ ok: false, error: 'URL, username and password are required' });
124
+ if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
125
+ try {
126
+ await getSession({ id: '__probe__', url, username, password, name: url });
127
+ res.json({ ok: true });
128
+ } catch (e) {
129
+ const msg = e.message || '';
130
+ console.error('[test-connection] error:', e);
131
+ const type = e.code === 'ERR_INVALID_URL' ? 'invalid_url'
132
+ : (msg.includes('Login failed') || msg.includes('CSRF token') || msg.includes('credentials')) ? 'auth'
133
+ : 'network';
134
+ res.json({ ok: false, error: msg, type });
135
+ }
136
+ });
137
+
138
+ app.post('/api/environments/:id/test', async (req, res) => {
139
+ const env = await getEnvironment(req.params.id);
140
+ if (!env) return res.status(404).json({ ok: false, error: 'Environment not found' });
141
+ try {
142
+ await getSession(env);
143
+ res.json({ ok: true });
144
+ } catch (e) {
145
+ res.json({ ok: false, error: e.message });
146
+ }
147
+ });
148
+
149
+ // HAC request log SSE
150
+ app.get('/api/hac-log', (req, res) => {
151
+ res.setHeader('Content-Type', 'text/event-stream');
152
+ res.setHeader('Cache-Control', 'no-cache');
153
+ res.setHeader('Connection', 'keep-alive');
154
+ res.flushHeaders();
155
+ for (const entry of hacLogBuffer) res.write(`data: ${JSON.stringify(entry)}\n\n`);
156
+ hacLogClients.add(res);
157
+ req.on('close', () => hacLogClients.delete(res));
158
+ });
159
+
160
+ // MCP activity log SSE
161
+ app.get('/api/mcp-log', (req, res) => {
162
+ res.setHeader('Content-Type', 'text/event-stream');
163
+ res.setHeader('Cache-Control', 'no-cache');
164
+ res.setHeader('Connection', 'keep-alive');
165
+ res.flushHeaders();
166
+ for (const entry of getMcpLogBuffer()) res.write(`data: ${JSON.stringify(entry)}\n\n`);
167
+ attachLogClient(res);
168
+ req.on('close', () => detachLogClient(res));
169
+ });
170
+
171
+ // Manifest API
172
+ app.get('/api/manifest', (_req, res) => {
173
+ res.json({
174
+ name: 'hac-mcp',
175
+ version: '1.0.0',
176
+ description: 'SAP Commerce Cloud HAC - MCP Server',
177
+ tools: allTools.map(t => ({
178
+ name: t.name,
179
+ category: t.category ?? 'utility',
180
+ description: t.description,
181
+ params: t.inputSchema
182
+ ? Object.entries(t.inputSchema).map(([name, schema]) => ({
183
+ name,
184
+ description: schema.description ?? schema._def?.description ?? null,
185
+ optional: schema.isOptional?.() === true,
186
+ }))
187
+ : [],
188
+ })),
189
+ });
190
+ });
191
+
192
+ // Status API
193
+ app.get('/api/status', async (_req, res) => {
194
+ const environments = await listEnvironments();
195
+ const clients = [...mcpSessions.values()].map(s => ({ ...(s.clientInfo ?? {}), connectedAt: s.connectedAt, toolCalls: s.toolCalls }));
196
+ res.json({ environmentCount: environments.length, connectedClients: mcpSessions.size, clients });
197
+ });
198
+
199
+ // MCP SSE
200
+ const mcpSessions = new Map();
201
+ app.get('/mcp/sse', async (_req, res) => {
202
+ const transport = new SSEServerTransport('/mcp/messages', res);
203
+ const clientNum = ++clientCounter;
204
+ const session = { mcp: null, transport, clientNum, clientInfo: null, connectedAt: Date.now(), toolCalls: 0 };
205
+ const mcp = createMcpInstance(sessionId => {
206
+ const s = mcpSessions.get(sessionId);
207
+ if (s) { s.toolCalls++; return clientLabel(s); }
208
+ return null;
209
+ });
210
+ session.mcp = mcp;
211
+ mcpSessions.set(transport.sessionId, session);
212
+ mcpLogSystem({ client: `Client #${clientNum}`, preview: 'connected via SSE' });
213
+ mcp.server.oninitialized = () => {
214
+ const version = mcp.server.getClientVersion() ?? null;
215
+ const caps = mcp.server.getClientCapabilities() ?? null;
216
+ session.clientInfo = { version, caps };
217
+ mcpLogSystem({ client: clientLabel(session), preview: 'initialized' });
218
+ };
219
+ res.on('close', () => {
220
+ mcpLogSystem({ client: clientLabel(session), preview: 'disconnected' });
221
+ mcpSessions.delete(transport.sessionId);
222
+ mcp.close();
223
+ });
224
+ await mcp.connect(transport);
225
+ });
226
+ app.post('/mcp/messages', async (req, res) => {
227
+ const session = mcpSessions.get(req.query.sessionId);
228
+ if (session) await session.transport.handlePostMessage(req, res, req.body);
229
+ else res.status(400).send('Unknown session');
230
+ });
231
+
232
+ // ─── Start ────────────────────────────────────────────────────────────────────
233
+ createServer(app).listen(PORT, () => {
234
+ const base = `http://localhost:${PORT}`;
235
+ const hasColor = process.stdout.hasColors?.() ?? process.stdout.isTTY;
236
+ const c = hasColor ? {
237
+ reset: '\x1b[0m',
238
+ bold: '\x1b[1m',
239
+ dim: '\x1b[2m',
240
+ green: '\x1b[32m',
241
+ cyan: '\x1b[36m',
242
+ white: '\x1b[97m',
243
+ } : { reset: '', bold: '', dim: '', green: '', cyan: '', white: '' };
244
+
245
+ // helpers
246
+ const label = s => `${c.dim}${s}${c.reset}`;
247
+ const value = s => `${c.cyan}${s}${c.reset}`;
248
+ const heading = s => `${c.bold}${c.white}${s}${c.reset}`;
249
+ const code = s => `${c.green}${s}${c.reset}`;
250
+
251
+ console.log('');
252
+ console.log(` ${c.bold}${c.green}HAC MCP is running${c.reset}`);
253
+ console.log('');
254
+ console.log(` ${label('Web UI ')} ${value(base)}`);
255
+ console.log(` ${label('MCP endpoint')} ${value(`${base}/mcp/sse`)}`);
256
+ console.log(` ${label('Config file ')} ${value(join(homedir(), '.hac-mcp', 'environments.json'))}`);
257
+ console.log('');
258
+ console.log(` ${label('Open the Web UI to add and manage your HAC environments.')}`);
259
+ console.log('');
260
+ console.log(` ${heading('Claude Code')}`);
261
+ console.log(` ${label('Run this command to register:')}`);
262
+ console.log('');
263
+ console.log(` ${code(`claude mcp add --transport sse hac-mcp ${base}/mcp/sse`)}`);
264
+ console.log('');
265
+ console.log(` ${heading('Other MCP Clients')}`);
266
+ console.log(` ${label('Add the following to your MCP client config:')}`);
267
+ console.log('');
268
+ console.log(` ${c.cyan}{${c.reset}`);
269
+ console.log(` ${c.cyan} "mcpServers": {${c.reset}`);
270
+ console.log(` ${code(' "hac-mcp": {')}`);
271
+ console.log(` ${code(` "url": "${base}/mcp/sse"`)}`);
272
+ console.log(` ${code(' }')}`);
273
+ console.log(` ${c.cyan} }${c.reset}`);
274
+ console.log(` ${c.cyan}}${c.reset}`);
275
+ console.log('');
276
+ });