sap-ai-dev-toolkit 0.3.3 → 0.3.7
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/.github/agents/abap-developer.agent.md +3 -1
- package/.github/agents/abap-runtime-debugger.agent.md +3 -1
- package/.github/agents/hana-cloud-hdi-specialist.agent.md +22 -0
- package/.github/agents/rap-service-developer.agent.md +3 -1
- package/.github/agents/sap-solution-architect.agent.md +6 -3
- package/.github/skills/hana-cloud-inspection/SKILL.md +35 -0
- package/.github/skills/hana-cloud-native-development/SKILL.md +26 -0
- package/.github/skills/hana-cloud-validation/SKILL.md +26 -0
- package/README.md +50 -22
- package/package.json +9 -4
- package/scripts/postinstall.mjs +3 -3
- package/src/bas-destination-relay.mjs +24 -13
- package/src/credential-overrides.mjs +75 -0
- package/src/credentials-store.mjs +14 -5
- package/src/hana-config.mjs +170 -0
- package/src/hana-database.mjs +150 -0
- package/src/hana-inspector.mjs +114 -0
- package/src/hana-tools.mjs +277 -0
- package/src/launcher.mjs +10 -32
- package/src/mcp-config.mjs +10 -1
- package/src/mcp-proxy.mjs +61 -0
- package/src/setup.mjs +87 -61
- package/test/bas-destination-relay.test.mjs +50 -0
- package/test/cf-runtime.test.mjs +27 -5
- package/test/copilot-content.test.mjs +63 -0
- package/test/credentials-store.test.mjs +31 -0
- package/test/hana-config.test.mjs +168 -0
- package/test/hana-inspector-stdio.test.mjs +44 -0
- package/test/hana-tools.test.mjs +206 -0
- package/test/launcher.test.mjs +52 -0
- package/test/live-s4h.test.mjs +202 -0
- package/test/mcp-config-cf.test.mjs +40 -0
- package/test/mcp-proxy.test.mjs +17 -31
- package/test/setup-cf.test.mjs +131 -4
- package/test/setup.test.mjs +10 -6
- package/tools.md +58 -50
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { quoteHanaIdentifier } from './hana-config.mjs';
|
|
3
|
+
|
|
4
|
+
export const HANA_MAX_ROWS = 200;
|
|
5
|
+
export const HANA_MAX_OUTPUT_BYTES = 96 * 1024;
|
|
6
|
+
const MAX_COLUMNS = 40;
|
|
7
|
+
const MAX_FILTERS = 20;
|
|
8
|
+
const MAX_SELECTED_COLUMN_WIDTH = 8192;
|
|
9
|
+
const UNBOUNDED_COLUMN_TYPES = new Set(['BLOB', 'CLOB', 'NCLOB', 'BINARY', 'VARBINARY', 'ST_GEOMETRY', 'ST_POINT']);
|
|
10
|
+
const SENSITIVE_COLUMN_NAME = /(?:PASS(?:WORD|WD)|SECRET|(?:ACCESS|REFRESH|AUTH)?_?TOKEN|API_?KEY|PRIVATE_?KEY|CREDENTIAL)/i;
|
|
11
|
+
const SAFE_INSPECTION_ERRORS = new Set([
|
|
12
|
+
'HANA result is too large to return safely; request fewer rows or columns.',
|
|
13
|
+
'The requested object was not found in the bound HDI schema.',
|
|
14
|
+
'A requested column is not present on the requested HDI object.',
|
|
15
|
+
'Credential-like columns are not returned by the HANA inspector.',
|
|
16
|
+
'LOB and binary columns are not supported by the bounded row-read tool.',
|
|
17
|
+
'The selected columns exceed the safe row-width limit; select fewer or narrower columns.'
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const READ_ONLY_ANNOTATIONS = Object.freeze({
|
|
21
|
+
readOnlyHint: true,
|
|
22
|
+
destructiveHint: false,
|
|
23
|
+
idempotentHint: true,
|
|
24
|
+
openWorldHint: false
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const OPERATOR_SQL = Object.freeze({
|
|
28
|
+
eq: '=',
|
|
29
|
+
ne: '<>',
|
|
30
|
+
lt: '<',
|
|
31
|
+
lte: '<=',
|
|
32
|
+
gt: '>',
|
|
33
|
+
gte: '>='
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function jsonSafe(value, seen = new WeakSet()) {
|
|
37
|
+
if (typeof value === 'bigint') return value.toString();
|
|
38
|
+
if (value instanceof Date) return value.toISOString();
|
|
39
|
+
if (Buffer.isBuffer(value)) return { type: 'binary', bytes: value.byteLength };
|
|
40
|
+
if (Array.isArray(value)) return value.map(entry => jsonSafe(entry, seen));
|
|
41
|
+
if (value && typeof value === 'object') {
|
|
42
|
+
if (seen.has(value)) return '[circular]';
|
|
43
|
+
seen.add(value);
|
|
44
|
+
const result = Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, jsonSafe(entry, seen)]));
|
|
45
|
+
seen.delete(value);
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
if (typeof value === 'number' && !Number.isFinite(value)) return String(value);
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function jsonText(value) {
|
|
53
|
+
const text = JSON.stringify(jsonSafe(value), null, 2);
|
|
54
|
+
if (Buffer.byteLength(text, 'utf8') > HANA_MAX_OUTPUT_BYTES) {
|
|
55
|
+
throw new Error('HANA result is too large to return safely; request fewer rows or columns.');
|
|
56
|
+
}
|
|
57
|
+
return text;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function toolError(error) {
|
|
61
|
+
let message = 'HANA inspection failed. Check the selected read-only binding, target schema, and read permissions.';
|
|
62
|
+
if (error?.name === 'HanaDatabaseError' || error?.name === 'HanaQueryTimeoutError') {
|
|
63
|
+
message = error.message;
|
|
64
|
+
} else if (SAFE_INSPECTION_ERRORS.has(error?.message)) {
|
|
65
|
+
message = error.message;
|
|
66
|
+
} else if (typeof error?.message === 'string' && /^(?:Set HANA_RO_|Explicit HANA |VCAP_SERVICES|The selected HANA |No HANA VCAP|Multiple HANA VCAP|The configured HANA |HANA_RO_HOST|HANA_RO_PORT)/.test(error.message)) {
|
|
67
|
+
message = error.message;
|
|
68
|
+
}
|
|
69
|
+
return { isError: true, content: [{ type: 'text', text: message }] };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function resultOf(operation) {
|
|
73
|
+
try {
|
|
74
|
+
return { content: [{ type: 'text', text: jsonText(await operation()) }] };
|
|
75
|
+
} catch (error) {
|
|
76
|
+
return toolError(error);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function rowsOf(value) {
|
|
81
|
+
return Array.isArray(value) ? value : [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function listObjects(database, schema, objectType, maxResults) {
|
|
85
|
+
const entries = [];
|
|
86
|
+
if (objectType !== 'VIEW') {
|
|
87
|
+
const tables = rowsOf(await database.query(
|
|
88
|
+
`SELECT TABLE_NAME AS OBJECT_NAME FROM SYS.TABLES WHERE SCHEMA_NAME = ? ORDER BY TABLE_NAME LIMIT ${maxResults}`,
|
|
89
|
+
[schema]
|
|
90
|
+
));
|
|
91
|
+
entries.push(...tables.map(row => ({ objectName: row.OBJECT_NAME, objectType: 'TABLE' })));
|
|
92
|
+
}
|
|
93
|
+
if (objectType !== 'TABLE') {
|
|
94
|
+
const views = rowsOf(await database.query(
|
|
95
|
+
`SELECT VIEW_NAME AS OBJECT_NAME FROM SYS.VIEWS WHERE SCHEMA_NAME = ? ORDER BY VIEW_NAME LIMIT ${maxResults}`,
|
|
96
|
+
[schema]
|
|
97
|
+
));
|
|
98
|
+
entries.push(...views.map(row => ({ objectName: row.OBJECT_NAME, objectType: 'VIEW' })));
|
|
99
|
+
}
|
|
100
|
+
entries.sort((left, right) => String(left.objectName).localeCompare(String(right.objectName)) || left.objectType.localeCompare(right.objectType));
|
|
101
|
+
return {
|
|
102
|
+
schema,
|
|
103
|
+
objectType,
|
|
104
|
+
objects: entries.slice(0, maxResults),
|
|
105
|
+
maxResults,
|
|
106
|
+
truncated: entries.length >= maxResults
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function findObject(database, schema, objectName) {
|
|
111
|
+
const tables = rowsOf(await database.query(
|
|
112
|
+
'SELECT TABLE_NAME AS OBJECT_NAME FROM SYS.TABLES WHERE SCHEMA_NAME = ? AND TABLE_NAME = ?',
|
|
113
|
+
[schema, objectName]
|
|
114
|
+
));
|
|
115
|
+
if (tables.length) return { objectName, objectType: 'TABLE' };
|
|
116
|
+
const views = rowsOf(await database.query(
|
|
117
|
+
'SELECT VIEW_NAME AS OBJECT_NAME FROM SYS.VIEWS WHERE SCHEMA_NAME = ? AND VIEW_NAME = ?',
|
|
118
|
+
[schema, objectName]
|
|
119
|
+
));
|
|
120
|
+
if (views.length) return { objectName, objectType: 'VIEW' };
|
|
121
|
+
throw new Error('The requested object was not found in the bound HDI schema.');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function describeObject(database, schema, objectName) {
|
|
125
|
+
const target = await findObject(database, schema, objectName);
|
|
126
|
+
const sql = target.objectType === 'TABLE'
|
|
127
|
+
? 'SELECT COLUMN_NAME, POSITION, DATA_TYPE_NAME, LENGTH, SCALE, IS_NULLABLE FROM SYS.TABLE_COLUMNS WHERE SCHEMA_NAME = ? AND TABLE_NAME = ? ORDER BY POSITION'
|
|
128
|
+
: 'SELECT COLUMN_NAME, POSITION, DATA_TYPE_NAME, LENGTH, SCALE, IS_NULLABLE FROM SYS.VIEW_COLUMNS WHERE SCHEMA_NAME = ? AND VIEW_NAME = ? ORDER BY POSITION';
|
|
129
|
+
const columns = rowsOf(await database.query(sql, [schema, objectName]));
|
|
130
|
+
return { ...target, schema, columns };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function assertRequestedColumns(columns, metadataColumns) {
|
|
134
|
+
const available = new Set(metadataColumns.map(column => column.COLUMN_NAME));
|
|
135
|
+
for (const name of columns) {
|
|
136
|
+
if (!available.has(name)) throw new Error('A requested column is not present on the requested HDI object.');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function assertBoundedColumns(columns, metadataColumns) {
|
|
141
|
+
let estimatedWidth = 0;
|
|
142
|
+
for (const name of columns) {
|
|
143
|
+
const column = metadataColumns.find(entry => entry.COLUMN_NAME === name);
|
|
144
|
+
const type = String(column.DATA_TYPE_NAME || '').toUpperCase();
|
|
145
|
+
if (UNBOUNDED_COLUMN_TYPES.has(type)) {
|
|
146
|
+
throw new Error('LOB and binary columns are not supported by the bounded row-read tool.');
|
|
147
|
+
}
|
|
148
|
+
const length = Number(column.LENGTH);
|
|
149
|
+
estimatedWidth += Number.isFinite(length) && length > 0 ? length : 32;
|
|
150
|
+
if (estimatedWidth > MAX_SELECTED_COLUMN_WIDTH) {
|
|
151
|
+
throw new Error('The selected columns exceed the safe row-width limit; select fewer or narrower columns.');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function assertNonSensitiveColumns(columns) {
|
|
157
|
+
if (columns.some(name => SENSITIVE_COLUMN_NAME.test(name))) {
|
|
158
|
+
throw new Error('Credential-like columns are not returned by the HANA inspector.');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function readRows(database, schema, args) {
|
|
163
|
+
const target = await findObject(database, schema, args.objectName);
|
|
164
|
+
const metadata = await describeObject(database, schema, args.objectName);
|
|
165
|
+
assertRequestedColumns(args.columns, metadata.columns);
|
|
166
|
+
assertNonSensitiveColumns([
|
|
167
|
+
...args.columns,
|
|
168
|
+
...args.filters.map(filter => filter.column),
|
|
169
|
+
...(args.orderBy ? [args.orderBy.column] : [])
|
|
170
|
+
]);
|
|
171
|
+
assertBoundedColumns(args.columns, metadata.columns);
|
|
172
|
+
|
|
173
|
+
for (const filter of args.filters) assertRequestedColumns([filter.column], metadata.columns);
|
|
174
|
+
if (args.orderBy) assertRequestedColumns([args.orderBy.column], metadata.columns);
|
|
175
|
+
|
|
176
|
+
const selected = args.columns.map(quoteHanaIdentifier).join(', ');
|
|
177
|
+
const conditions = args.filters.map(filter => `${quoteHanaIdentifier(filter.column)} ${OPERATOR_SQL[filter.operator]} ?`);
|
|
178
|
+
const order = args.orderBy
|
|
179
|
+
? ` ORDER BY ${quoteHanaIdentifier(args.orderBy.column)} ${args.orderBy.direction}`
|
|
180
|
+
: '';
|
|
181
|
+
const where = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '';
|
|
182
|
+
const sql = `SELECT ${selected} FROM ${quoteHanaIdentifier(schema)}.${quoteHanaIdentifier(args.objectName)}${where}${order} LIMIT ${args.limit}`;
|
|
183
|
+
const values = args.filters.map(filter => filter.value);
|
|
184
|
+
const rows = rowsOf(await database.query(sql, values));
|
|
185
|
+
return {
|
|
186
|
+
schema,
|
|
187
|
+
...target,
|
|
188
|
+
columns: args.columns,
|
|
189
|
+
limit: args.limit,
|
|
190
|
+
rowCount: rows.length,
|
|
191
|
+
rows,
|
|
192
|
+
possiblyTruncated: rows.length === args.limit
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Register read-only inspection tools. No handler accepts arbitrary SQL or a caller-selected schema. */
|
|
197
|
+
export function registerHanaTools(server, getDatabase, { connectionSummary } = {}) {
|
|
198
|
+
const schemaName = connectionSummary?.schema;
|
|
199
|
+
if (typeof schemaName !== 'string' || !schemaName.trim()) {
|
|
200
|
+
throw new Error('A configured HDI schema is required to register HANA tools.');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
server.registerTool('hana_connection_info', {
|
|
204
|
+
title: 'HANA connection info',
|
|
205
|
+
description: 'Return the configured HANA Cloud endpoint and verified HDI schema/current user. Uses the dedicated read-only identity; never returns credentials.',
|
|
206
|
+
inputSchema: {},
|
|
207
|
+
annotations: READ_ONLY_ANNOTATIONS
|
|
208
|
+
}, async () => resultOf(async () => {
|
|
209
|
+
const database = await getDatabase();
|
|
210
|
+
const rows = rowsOf(await database.query('SELECT CURRENT_SCHEMA AS CURRENT_SCHEMA, CURRENT_USER AS CURRENT_USER FROM DUMMY'));
|
|
211
|
+
if (!rows.length) throw new Error('HANA did not return its current connection identity.');
|
|
212
|
+
const currentSchema = rows[0].CURRENT_SCHEMA;
|
|
213
|
+
if (currentSchema !== schemaName) throw new Error('The connected HANA schema does not match the configured HDI schema.');
|
|
214
|
+
return {
|
|
215
|
+
source: connectionSummary.source,
|
|
216
|
+
serviceName: connectionSummary.serviceName,
|
|
217
|
+
bindingName: connectionSummary.bindingName,
|
|
218
|
+
host: connectionSummary.host,
|
|
219
|
+
port: connectionSummary.port,
|
|
220
|
+
configuredSchema: schemaName,
|
|
221
|
+
currentSchema,
|
|
222
|
+
databaseUser: rows[0].CURRENT_USER,
|
|
223
|
+
tls: 'enabled; certificate verification required'
|
|
224
|
+
};
|
|
225
|
+
}));
|
|
226
|
+
|
|
227
|
+
server.registerTool('hana_list_objects', {
|
|
228
|
+
title: 'List HANA objects',
|
|
229
|
+
description: 'List tables and/or views from the configured HDI schema only. Results are capped at 200 objects.',
|
|
230
|
+
inputSchema: {
|
|
231
|
+
objectType: z.enum(['ALL', 'TABLE', 'VIEW']).default('ALL'),
|
|
232
|
+
maxResults: z.number().int().min(1).max(HANA_MAX_ROWS).default(100)
|
|
233
|
+
},
|
|
234
|
+
annotations: READ_ONLY_ANNOTATIONS
|
|
235
|
+
}, async ({ objectType, maxResults }) => resultOf(async () => {
|
|
236
|
+
return listObjects(await getDatabase(), schemaName, objectType, maxResults);
|
|
237
|
+
}));
|
|
238
|
+
|
|
239
|
+
server.registerTool('hana_describe_object', {
|
|
240
|
+
title: 'Describe HANA object',
|
|
241
|
+
description: 'Return column metadata for a table or view in the configured HDI schema only.',
|
|
242
|
+
inputSchema: { objectName: z.string().trim().min(1).max(127) },
|
|
243
|
+
annotations: READ_ONLY_ANNOTATIONS
|
|
244
|
+
}, async ({ objectName }) => resultOf(async () => {
|
|
245
|
+
return describeObject(await getDatabase(), schemaName, objectName);
|
|
246
|
+
}));
|
|
247
|
+
|
|
248
|
+
const valueSchema = z.union([z.string().max(2048), z.number().finite(), z.boolean()]);
|
|
249
|
+
const filterSchema = z.object({
|
|
250
|
+
column: z.string().trim().min(1).max(127),
|
|
251
|
+
operator: z.enum(['eq', 'ne', 'lt', 'lte', 'gt', 'gte']),
|
|
252
|
+
value: valueSchema
|
|
253
|
+
}).strict();
|
|
254
|
+
|
|
255
|
+
server.registerTool('hana_read_rows', {
|
|
256
|
+
title: 'Read HANA rows',
|
|
257
|
+
description: `Run a bounded, parameterized read from one catalog-listed table/view in the configured HDI schema. Select 1–${MAX_COLUMNS} known columns; no free-form SQL, DDL, DML, procedure calls, or schema selection is accepted. Maximum ${HANA_MAX_ROWS} rows.`,
|
|
258
|
+
inputSchema: {
|
|
259
|
+
objectName: z.string().trim().min(1).max(127),
|
|
260
|
+
columns: z.array(z.string().trim().min(1).max(127)).min(1).max(MAX_COLUMNS),
|
|
261
|
+
filters: z.array(filterSchema).max(MAX_FILTERS).default([]),
|
|
262
|
+
orderBy: z.object({
|
|
263
|
+
column: z.string().trim().min(1).max(127),
|
|
264
|
+
direction: z.enum(['ASC', 'DESC']).default('ASC')
|
|
265
|
+
}).strict().optional(),
|
|
266
|
+
limit: z.number().int().min(1).max(HANA_MAX_ROWS).default(50)
|
|
267
|
+
},
|
|
268
|
+
annotations: READ_ONLY_ANNOTATIONS
|
|
269
|
+
}, async args => resultOf(async () => readRows(await getDatabase(), schemaName, args)));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export const HANA_TOOL_NAMES = Object.freeze([
|
|
273
|
+
'hana_connection_info',
|
|
274
|
+
'hana_list_objects',
|
|
275
|
+
'hana_describe_object',
|
|
276
|
+
'hana_read_rows'
|
|
277
|
+
]);
|
package/src/launcher.mjs
CHANGED
|
@@ -10,36 +10,11 @@ import { installMcpConfig } from './mcp-config.mjs';
|
|
|
10
10
|
import { runSetup } from './setup.mjs';
|
|
11
11
|
import { resolveConfiguredCloudFoundryDestination } from './cf-destination.mjs';
|
|
12
12
|
import { withBrandedEnvironment } from './branding.mjs';
|
|
13
|
-
import {
|
|
13
|
+
import { enrichWithStoredCredentials } from './credential-overrides.mjs';
|
|
14
14
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
15
15
|
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
16
16
|
const runtimeEnv = withBrandedEnvironment(process.env);
|
|
17
17
|
|
|
18
|
-
// Attach stored direct-connect credentials (setup prompts for Internet
|
|
19
|
-
// destinations) to matching discovered destinations. Credentials stay in
|
|
20
|
-
// memory only; nothing is logged or written to the MCP config.
|
|
21
|
-
async function enrichWithStoredCredentials(destinations, env = runtimeEnv) {
|
|
22
|
-
const needingCredentials = destinations.filter(destination =>
|
|
23
|
-
destination.source !== 'cloud-foundry'
|
|
24
|
-
&& destination.url
|
|
25
|
-
&& String(destination.url).includes('.dest'));
|
|
26
|
-
if (!needingCredentials.length) return destinations;
|
|
27
|
-
let stored;
|
|
28
|
-
try {
|
|
29
|
-
const path = await resolveCredentialsPath(env);
|
|
30
|
-
stored = await readCredentials(path);
|
|
31
|
-
} catch (error) {
|
|
32
|
-
console.error(`[sap-ai-dev] stored credentials could not be read: ${String(error.message).slice(0, 200)}`);
|
|
33
|
-
return destinations;
|
|
34
|
-
}
|
|
35
|
-
return destinations.map(destination => {
|
|
36
|
-
const entry = stored.destinations?.[destination.name];
|
|
37
|
-
if (!entry?.host || !entry.user || !entry.password) return destination;
|
|
38
|
-
if (!destination.backendUrl) return destination;
|
|
39
|
-
return { ...destination, credentials: { host: entry.host, user: entry.user, password: entry.password } };
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
|
|
43
18
|
function hasFlag(name) { return process.argv.slice(2).includes(name); }
|
|
44
19
|
function json(value) { process.stdout.write(`${JSON.stringify(value)}\n`); }
|
|
45
20
|
|
|
@@ -111,7 +86,9 @@ async function runDoctor(destinations) {
|
|
|
111
86
|
} else {
|
|
112
87
|
checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Not applicable for this destination.'));
|
|
113
88
|
}
|
|
114
|
-
if (relayedDestination?.credentials) {
|
|
89
|
+
if (relayedDestination?.credentials?.mode === 'bas-tunnel') {
|
|
90
|
+
checks.push(doctorRow(destination.name, 'OnPremise credential override', 'passed', 'Basic auth is routed through a BAS proxy tunnel; Cloud Connector routing is retained'));
|
|
91
|
+
} else if (relayedDestination?.credentials?.host) {
|
|
115
92
|
checks.push(doctorRow(destination.name, 'Direct connect', 'passed', `active (${new URL(relayedDestination.credentials.host).origin}); ADT writes use stored credentials`));
|
|
116
93
|
}
|
|
117
94
|
const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
@@ -203,8 +180,8 @@ async function main() {
|
|
|
203
180
|
let report;
|
|
204
181
|
try {
|
|
205
182
|
const destinations = runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry'
|
|
206
|
-
? [await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })]
|
|
207
|
-
: await enrichWithStoredCredentials(await discoverForCommand());
|
|
183
|
+
? await enrichWithStoredCredentials([await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })], runtimeEnv)
|
|
184
|
+
: await enrichWithStoredCredentials(await discoverForCommand(), runtimeEnv);
|
|
208
185
|
report = await runDoctor(destinations);
|
|
209
186
|
} catch (error) {
|
|
210
187
|
report = { ok: false, destinations: 0, checks: [doctorRow('-', 'destination discovery', 'failed', redactText(error.message || error).slice(0, 300))] };
|
|
@@ -240,7 +217,7 @@ async function main() {
|
|
|
240
217
|
}
|
|
241
218
|
|
|
242
219
|
if (runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry') {
|
|
243
|
-
const destination = await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv });
|
|
220
|
+
const destination = (await enrichWithStoredCredentials([await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })], runtimeEnv))[0];
|
|
244
221
|
let proxy;
|
|
245
222
|
try {
|
|
246
223
|
const binary = await binaryOrError();
|
|
@@ -270,14 +247,15 @@ async function main() {
|
|
|
270
247
|
|
|
271
248
|
const discovered = await discoverDestinations({ env: runtimeEnv });
|
|
272
249
|
for (const destination of discovered) console.error(probeDiagnostic(destination));
|
|
273
|
-
const destinations = await enrichWithStoredCredentials(discovered);
|
|
250
|
+
const destinations = await enrichWithStoredCredentials(discovered, runtimeEnv);
|
|
274
251
|
if (!destinations.length) {
|
|
275
252
|
console.error('[sap-ai-dev] destination discovery returned no named BAS destinations');
|
|
276
253
|
throw new Error(remediation);
|
|
277
254
|
}
|
|
278
255
|
const binary = await binaryOrError();
|
|
279
256
|
for (const destination of destinations) {
|
|
280
|
-
if (destination.credentials) console.error(`[sap-ai-dev] ${destination.name}:
|
|
257
|
+
if (destination.credentials?.mode === 'bas-tunnel') console.error(`[sap-ai-dev] ${destination.name}: Basic credentials enabled through the BAS proxy tunnel`);
|
|
258
|
+
else if (destination.credentials?.host) console.error(`[sap-ai-dev] ${destination.name}: direct connect enabled through stored credentials`);
|
|
281
259
|
}
|
|
282
260
|
console.error(`[sap-ai-dev] starting MCP proxy for ${destinations.map(destination => `${destination.name} (client=${destination.client})`).join(', ')}`);
|
|
283
261
|
const proxy = new MCPProxy({ binary, destinations, env: process.env, log: message => console.error(message) });
|
package/src/mcp-config.mjs
CHANGED
|
@@ -39,6 +39,15 @@ export const SAP_DEVELOPMENT_MCP_SERVERS = Object.freeze([
|
|
|
39
39
|
packageName: '@playwright/mcp',
|
|
40
40
|
bin: 'playwright-mcp',
|
|
41
41
|
priority: 'recommended'
|
|
42
|
+
}),
|
|
43
|
+
Object.freeze({
|
|
44
|
+
id: 'hana-cloud-inspector',
|
|
45
|
+
name: 'HANA Cloud inspector',
|
|
46
|
+
description: 'Read-only HANA Cloud/HDI metadata and bounded row inspection using host-provided HANA_RO_* or VCAP_SERVICES credentials.',
|
|
47
|
+
packageName: 'sap-ai-dev-toolkit',
|
|
48
|
+
bin: 'sap-ai-hana',
|
|
49
|
+
ignoreScripts: true,
|
|
50
|
+
priority: 'optional'
|
|
42
51
|
})
|
|
43
52
|
]);
|
|
44
53
|
|
|
@@ -78,7 +87,7 @@ export function buildSapDevelopmentMcpEntries(serverIds = SAP_DEVELOPMENT_MCP_SE
|
|
|
78
87
|
entries[server.id] = {
|
|
79
88
|
type: 'stdio',
|
|
80
89
|
command: packageManager,
|
|
81
|
-
args: ['--yes', `--package=${packageSpec}`, server.bin],
|
|
90
|
+
args: ['--yes', ...(server.ignoreScripts ? ['--ignore-scripts'] : []), `--package=${packageSpec}`, server.bin],
|
|
82
91
|
BAS_EXT: 'true',
|
|
83
92
|
BAS_EXT_KIND: COMPANION_SERVER_KIND,
|
|
84
93
|
displayName: server.name,
|
package/src/mcp-proxy.mjs
CHANGED
|
@@ -34,6 +34,66 @@ const APPLICATION_LOG_SCHEMA = {
|
|
|
34
34
|
};
|
|
35
35
|
const APPLICATION_LOG_DESCRIPTION = 'Read SAP application log (SLG1) entries. Results are newest first and limited to 100 by default; set messages=true to include log message details.';
|
|
36
36
|
|
|
37
|
+
// Keep the default public VSP surface small enough for developer-lifecycle use.
|
|
38
|
+
// Hidden upstream tools can still be used by local workflow tools when needed.
|
|
39
|
+
export const PUBLIC_VSP_TOOLS = new Set([
|
|
40
|
+
'Activate',
|
|
41
|
+
'ActivateMultiple',
|
|
42
|
+
'CompareSource',
|
|
43
|
+
'CreateTransport',
|
|
44
|
+
'DebuggerAttach',
|
|
45
|
+
'DebuggerDetach',
|
|
46
|
+
'DebuggerGetStack',
|
|
47
|
+
'DebuggerGetVariables',
|
|
48
|
+
'DebuggerStep',
|
|
49
|
+
'EditSource',
|
|
50
|
+
'FindDefinition',
|
|
51
|
+
'FindReferences',
|
|
52
|
+
'GetAPIReleaseState',
|
|
53
|
+
'GetBreakpoints',
|
|
54
|
+
'GetCDSDependencies',
|
|
55
|
+
'GetCDSElementInfo',
|
|
56
|
+
'GetCDSImpactAnalysis',
|
|
57
|
+
'GetClass',
|
|
58
|
+
'GetClassComponents',
|
|
59
|
+
'GetClassInclude',
|
|
60
|
+
'GetClassInfo',
|
|
61
|
+
'GetConnectionInfo',
|
|
62
|
+
'GetContext',
|
|
63
|
+
'GetFeatures',
|
|
64
|
+
'GetFunction',
|
|
65
|
+
'GetFunctionGroup',
|
|
66
|
+
'GetInactiveObjects',
|
|
67
|
+
'GetInclude',
|
|
68
|
+
'GetInstalledComponents',
|
|
69
|
+
'GetInterface',
|
|
70
|
+
'GetPackage',
|
|
71
|
+
'GetProgram',
|
|
72
|
+
'GetSource',
|
|
73
|
+
'GetSystemInfo',
|
|
74
|
+
'GetTable',
|
|
75
|
+
'GetTableContents',
|
|
76
|
+
'GetTransport',
|
|
77
|
+
'GetTransportInfo',
|
|
78
|
+
'GetUserTransports',
|
|
79
|
+
'GrepObjects',
|
|
80
|
+
'GrepPackages',
|
|
81
|
+
'ListDependencies',
|
|
82
|
+
'ListTransports',
|
|
83
|
+
'PrettyPrint',
|
|
84
|
+
'RunATCCheck',
|
|
85
|
+
'RunQuery',
|
|
86
|
+
'RunUnitTests',
|
|
87
|
+
'SearchObject',
|
|
88
|
+
'SetBreakpoint',
|
|
89
|
+
'SyntaxCheck',
|
|
90
|
+
'WriteSource'
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
function exposeVspTool(tool) {
|
|
94
|
+
return PUBLIC_VSP_TOOLS.has(tool?.name);
|
|
95
|
+
}
|
|
96
|
+
|
|
37
97
|
function applicationLogArguments(arguments_ = {}) {
|
|
38
98
|
const filters = arguments_ && typeof arguments_ === 'object' && !Array.isArray(arguments_) ? arguments_ : {};
|
|
39
99
|
const params = { type: 'application_log' };
|
|
@@ -358,6 +418,7 @@ export class MCPProxy {
|
|
|
358
418
|
});
|
|
359
419
|
}
|
|
360
420
|
|
|
421
|
+
if (!exposeVspTool(tool)) continue;
|
|
361
422
|
const name = `${slug}__${tool.name}`;
|
|
362
423
|
this.namespace.set(name, { entry, upstream: tool.name });
|
|
363
424
|
merged.push({ ...tool, name, description: `${tool.description || tool.name} [destination: ${entry.destination.name}]` });
|