@gavdi/cap-mcp 1.4.1 → 1.5.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/README.md +53 -12
- package/lib/annotations/constants.js +1 -0
- package/lib/auth/factory.js +33 -28
- package/lib/auth/host-resolver.js +146 -0
- package/lib/auth/utils.js +23 -32
- package/lib/config/loader.js +1 -0
- package/lib/mcp/entity-tools.js +103 -21
- package/lib/mcp/factory.js +4 -2
- package/lib/mcp/utils.js +12 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# CAP MCP Plugin - AI With Ease
|
|
2
|
-
   
|
|
3
|
-
|
|
4
2
|
|
|
3
|
+
   
|
|
5
4
|
|
|
6
5
|
> This implementation is based on the Model Context Protocol (MCP) put forward by Anthropic.
|
|
7
6
|
> For more information on MCP, please have a look at their [official documentation.](https://modelcontextprotocol.io/introduction)
|
|
@@ -99,6 +98,7 @@ cds serve
|
|
|
99
98
|
```
|
|
100
99
|
|
|
101
100
|
The MCP server will be available at:
|
|
101
|
+
|
|
102
102
|
- **MCP Endpoint**: `http://localhost:4004/mcp`
|
|
103
103
|
- **Health Check**: `http://localhost:4004/mcp/health`
|
|
104
104
|
|
|
@@ -176,6 +176,7 @@ service CatalogService {
|
|
|
176
176
|
```
|
|
177
177
|
|
|
178
178
|
**Generated MCP Resource Capabilities:**
|
|
179
|
+
|
|
179
180
|
- **OData v4 Query Support**: `$filter`, `$orderby`, `$top`, `$skip`, `$select`, `$expand`
|
|
180
181
|
- **Natural Language Queries**: "Find books by Stephen King with stock > 20"
|
|
181
182
|
- **Dynamic Filtering**: Complex filter expressions using OData syntax
|
|
@@ -236,23 +237,27 @@ entity Users {
|
|
|
236
237
|
```
|
|
237
238
|
|
|
238
239
|
**How It Works:**
|
|
240
|
+
|
|
239
241
|
- Fields marked with `@mcp.omit` are automatically filtered from all MCP responses
|
|
240
242
|
- Applies to:
|
|
241
243
|
- **Resources**: Field will not appear in resource read operations
|
|
242
244
|
- **Wrapped Entities**: Omission applies to all entity wrapper operations
|
|
243
245
|
|
|
244
246
|
**Common Use Cases:**
|
|
247
|
+
|
|
245
248
|
- **Security**: Hide information sensitive to functionality or business operations
|
|
246
249
|
- **Privacy**: Protect personal identifiers
|
|
247
250
|
- **Internal Data**: Exclude internal notes, audit logs, or system-only fields
|
|
248
251
|
- **Compliance**: Ensure GDPR/CCPA compliance by hiding sensitive personal data
|
|
249
252
|
|
|
250
253
|
**Important Notes:**
|
|
254
|
+
|
|
251
255
|
- Omitted fields are **only excluded from outputs** - they can still be provided as inputs for create/update operations
|
|
252
256
|
- The annotation works alongside the CAP standard annotation `@Core.Computed` for comprehensive field control
|
|
253
257
|
- Omitted fields remain queryable in the CAP service - only MCP responses are filtered
|
|
254
258
|
|
|
255
259
|
**Example with Multiple Annotations:**
|
|
260
|
+
|
|
256
261
|
```cds
|
|
257
262
|
entity Products {
|
|
258
263
|
key ID : Integer;
|
|
@@ -325,11 +330,13 @@ function getBooksByAuthor(authorName: String) returns array of String;
|
|
|
325
330
|
> NOTE: Elicitation is only available for direct tools at this moment. Wrapped entities are not covered by this.
|
|
326
331
|
|
|
327
332
|
**Elicit Types:**
|
|
333
|
+
|
|
328
334
|
- **`confirm`**: Requests user confirmation before executing the tool with a yes/no prompt
|
|
329
335
|
- **`input`**: Prompts the user to provide values for the tool's parameters
|
|
330
336
|
- **Combined**: Use both `['input', 'confirm']` to first collect parameters, then ask for confirmation
|
|
331
337
|
|
|
332
338
|
**User Experience:**
|
|
339
|
+
|
|
333
340
|
- **Confirmation**: "Please confirm that you want to perform action 'Get a random book recommendation'"
|
|
334
341
|
- **Input**: "Please fill out the required parameters" with a form for each parameter
|
|
335
342
|
- **User Actions**: Accept, decline, or cancel the elicitation request
|
|
@@ -342,6 +349,7 @@ Provide contextual descriptions for individual properties and parameters using t
|
|
|
342
349
|
#### Where to Use Hints
|
|
343
350
|
|
|
344
351
|
**Resource Entity Properties**
|
|
352
|
+
|
|
345
353
|
```cds
|
|
346
354
|
entity Books {
|
|
347
355
|
key ID : Integer @mcp.hint: 'Must be a unique number not already in the system';
|
|
@@ -351,6 +359,7 @@ entity Books {
|
|
|
351
359
|
```
|
|
352
360
|
|
|
353
361
|
**Array Elements**
|
|
362
|
+
|
|
354
363
|
```cds
|
|
355
364
|
entity Authors {
|
|
356
365
|
key ID : Integer;
|
|
@@ -360,6 +369,7 @@ entity Authors {
|
|
|
360
369
|
```
|
|
361
370
|
|
|
362
371
|
**Function/Action Parameters**
|
|
372
|
+
|
|
363
373
|
```cds
|
|
364
374
|
@mcp: {
|
|
365
375
|
name : 'books-by-author',
|
|
@@ -372,6 +382,7 @@ function getBooksByAuthor(
|
|
|
372
382
|
```
|
|
373
383
|
|
|
374
384
|
**Complex Type Fields**
|
|
385
|
+
|
|
375
386
|
```cds
|
|
376
387
|
type TValidQuantities {
|
|
377
388
|
positiveOnly : Integer @mcp.hint: 'Only takes in positive numbers, i.e. no negative values such as -1'
|
|
@@ -381,6 +392,7 @@ type TValidQuantities {
|
|
|
381
392
|
#### How Hints Are Used
|
|
382
393
|
|
|
383
394
|
Hints are automatically incorporated into:
|
|
395
|
+
|
|
384
396
|
- **Resource Descriptions**: Field-level guidance in entity wrapper tools (query/get/create/update/delete)
|
|
385
397
|
- **Tool Parameter Schemas**: Enhanced parameter descriptions visible to AI agents
|
|
386
398
|
- **Input Validation**: Context for AI agents when constructing function calls
|
|
@@ -388,6 +400,7 @@ Hints are automatically incorporated into:
|
|
|
388
400
|
#### Example: Enhanced Tool Experience
|
|
389
401
|
|
|
390
402
|
Without `@mcp.hint`:
|
|
403
|
+
|
|
391
404
|
```json
|
|
392
405
|
{
|
|
393
406
|
"tool": "CatalogService_Books_create",
|
|
@@ -399,6 +412,7 @@ Without `@mcp.hint`:
|
|
|
399
412
|
```
|
|
400
413
|
|
|
401
414
|
With `@mcp.hint`:
|
|
415
|
+
|
|
402
416
|
```json
|
|
403
417
|
{
|
|
404
418
|
"tool": "CatalogService_Books_create",
|
|
@@ -418,16 +432,20 @@ With `@mcp.hint`:
|
|
|
418
432
|
#### Best Practices
|
|
419
433
|
|
|
420
434
|
1. **Be Specific**: Provide concrete examples and constraints
|
|
435
|
+
|
|
421
436
|
- ❌ Bad: `@mcp.hint: 'Author name'`
|
|
422
437
|
- ✅ Good: `@mcp.hint: 'Full name of the author (e.g., "Ernest Hemingway")'`
|
|
423
438
|
|
|
424
439
|
2. **Include Constraints**: Document validation rules and business logic
|
|
440
|
+
|
|
425
441
|
- ✅ `@mcp.hint: 'Must be between 0 and 999, representing quantity in stock'`
|
|
426
442
|
|
|
427
443
|
3. **Clarify Foreign Keys**: Help AI agents understand associations
|
|
444
|
+
|
|
428
445
|
- ✅ `@mcp.hint: 'Foreign key reference to Authors.ID'`
|
|
429
446
|
|
|
430
447
|
4. **Explain Business Context**: Add domain-specific information
|
|
448
|
+
|
|
431
449
|
- ✅ `@mcp.hint: 'ISBN-13 format, used for unique book identification'`
|
|
432
450
|
|
|
433
451
|
5. **Avoid Redundancy**: Don't repeat what's obvious from the field name and type
|
|
@@ -492,22 +510,24 @@ Configure the MCP plugin through your CAP application's `package.json` or `.cdsr
|
|
|
492
510
|
|
|
493
511
|
### Configuration Options
|
|
494
512
|
|
|
495
|
-
| Option
|
|
496
|
-
|
|
497
|
-
| `name`
|
|
498
|
-
| `version`
|
|
499
|
-
| `auth`
|
|
500
|
-
| `instructions`
|
|
501
|
-
| `
|
|
502
|
-
| `capabilities.resources.
|
|
503
|
-
| `capabilities.
|
|
504
|
-
| `capabilities.
|
|
513
|
+
| Option | Type | Default | Description |
|
|
514
|
+
| ------------------------------------ | ----------------------- | -------------------- | --------------------------------------------------------------------------- |
|
|
515
|
+
| `name` | string | package.json name | MCP server name |
|
|
516
|
+
| `version` | string | package.json version | MCP server version |
|
|
517
|
+
| `auth` | `"inherit"` \| `"none"` | `"inherit"` | Authentication mode |
|
|
518
|
+
| `instructions` | string | `null` | MCP server instructions for agents |
|
|
519
|
+
| `enable_model_description` | boolean | `true` | Determines whether the MCP server should include the model description tool |
|
|
520
|
+
| `capabilities.resources.listChanged` | boolean | `true` | Enable resource list change notifications |
|
|
521
|
+
| `capabilities.resources.subscribe` | boolean | `false` | Enable resource subscriptions |
|
|
522
|
+
| `capabilities.tools.listChanged` | boolean | `true` | Enable tool list change notifications |
|
|
523
|
+
| `capabilities.prompts.listChanged` | boolean | `true` | Enable prompt list change notifications |
|
|
505
524
|
|
|
506
525
|
### Authentication Configuration
|
|
507
526
|
|
|
508
527
|
The plugin supports two authentication modes:
|
|
509
528
|
|
|
510
529
|
#### `"inherit"` Mode (Default)
|
|
530
|
+
|
|
511
531
|
Uses your CAP application's existing authentication system:
|
|
512
532
|
|
|
513
533
|
```json
|
|
@@ -526,6 +546,7 @@ Uses your CAP application's existing authentication system:
|
|
|
526
546
|
```
|
|
527
547
|
|
|
528
548
|
#### `"none"` Mode (Development/Testing)
|
|
549
|
+
|
|
529
550
|
Disables authentication completely:
|
|
530
551
|
|
|
531
552
|
```json
|
|
@@ -541,6 +562,7 @@ Disables authentication completely:
|
|
|
541
562
|
**⚠️ Security Warning**: Only use `"none"` mode in development environments. Never deploy to production without proper authentication.
|
|
542
563
|
|
|
543
564
|
#### Authentication Flow
|
|
565
|
+
|
|
544
566
|
1. MCP client connects to `/mcp` endpoint
|
|
545
567
|
2. If the authentication style used is OAuth, the OAuth flow will be executed
|
|
546
568
|
3. CAP authentication middleware validates credentials (if `auth: "inherit"`)
|
|
@@ -550,6 +572,7 @@ Disables authentication completely:
|
|
|
550
572
|
### Automatic Features
|
|
551
573
|
|
|
552
574
|
The plugin automatically:
|
|
575
|
+
|
|
553
576
|
- Scans your CAP service definitions for `@mcp` annotations
|
|
554
577
|
- Generates appropriate MCP resources, tools, and prompts
|
|
555
578
|
- Creates ResourceTemplates with proper OData v4 query parameter support
|
|
@@ -570,6 +593,7 @@ While this shows how this example CDS annotation works, the possibilities are en
|
|
|
570
593
|
## 📋 Business Case Example: Workflow Approval Management
|
|
571
594
|
|
|
572
595
|
### The Setup
|
|
596
|
+
|
|
573
597
|
Your CAP service includes a workflow management system with MCP integration:
|
|
574
598
|
|
|
575
599
|
```cds
|
|
@@ -587,16 +611,19 @@ service WorkflowService {
|
|
|
587
611
|
### The Interaction Flow
|
|
588
612
|
|
|
589
613
|
**1. User Query**
|
|
614
|
+
|
|
590
615
|
```
|
|
591
616
|
User: "Hey <Agent>, do I have any workflows pending approval?"
|
|
592
617
|
```
|
|
593
618
|
|
|
594
619
|
**2. AI Agent Processing**
|
|
620
|
+
|
|
595
621
|
- Agent recognizes this as a request for pending approval information
|
|
596
622
|
- Identifies the `get-my-pending-approval` tool as the appropriate method
|
|
597
623
|
- Determines the user's ID from context (session, authentication, etc.)
|
|
598
624
|
|
|
599
625
|
**3. MCP Tool Execution**
|
|
626
|
+
|
|
600
627
|
```javascript
|
|
601
628
|
// Agent calls the MCP tool
|
|
602
629
|
{
|
|
@@ -608,12 +635,14 @@ User: "Hey <Agent>, do I have any workflows pending approval?"
|
|
|
608
635
|
```
|
|
609
636
|
|
|
610
637
|
**4. CAP Service Processing**
|
|
638
|
+
|
|
611
639
|
- Your CAP service receives the tool call
|
|
612
640
|
- Executes `getPendingApproval("john.doe@company.com")`
|
|
613
641
|
- Queries your workflow database/system
|
|
614
642
|
- Returns structured workflow data
|
|
615
643
|
|
|
616
644
|
**5. AI Response**
|
|
645
|
+
|
|
617
646
|
```
|
|
618
647
|
Agent: "You have 3 workflows pending your approval:
|
|
619
648
|
|
|
@@ -636,6 +665,7 @@ Would you like me to help you review any of these in detail?"
|
|
|
636
665
|
```
|
|
637
666
|
|
|
638
667
|
### Business Value
|
|
668
|
+
|
|
639
669
|
- **Instant Access**: No need to log into workflow systems or navigate complex UIs
|
|
640
670
|
- **Contextual Intelligence**: AI can prioritize based on urgency, amounts, or business rules
|
|
641
671
|
- **Natural Interaction**: Users can ask follow-up questions in plain language
|
|
@@ -702,11 +732,13 @@ This project is licensed under the Apache-2.0 License - see the [LICENSE.md](LIC
|
|
|
702
732
|
### Common Issues
|
|
703
733
|
|
|
704
734
|
#### MCP Server Not Starting
|
|
735
|
+
|
|
705
736
|
- **Check Port Availability**: Ensure port 4004 is not in use by another process
|
|
706
737
|
- **Verify CAP Service**: Make sure your CAP application starts successfully with `cds serve`
|
|
707
738
|
- **Authentication Issues**: If using `auth: "inherit"`, ensure your CAP authentication is properly configured
|
|
708
739
|
|
|
709
740
|
#### MCP Client Connection Failures
|
|
741
|
+
|
|
710
742
|
```bash
|
|
711
743
|
# Check if MCP endpoint is accessible
|
|
712
744
|
curl http://localhost:4004/mcp/health
|
|
@@ -716,21 +748,25 @@ curl http://localhost:4004/mcp/health
|
|
|
716
748
|
```
|
|
717
749
|
|
|
718
750
|
#### Annotation Not Working
|
|
751
|
+
|
|
719
752
|
- **Syntax Check**: Verify your `@mcp` annotation syntax matches the examples
|
|
720
753
|
- **Service Deployment**: Ensure annotated entities/functions are properly deployed
|
|
721
754
|
- **Case Sensitivity**: Check that annotation properties use correct casing (`resource`, `tool`, `prompts`)
|
|
722
755
|
|
|
723
756
|
#### OData Query Issues
|
|
757
|
+
|
|
724
758
|
- **SDK Bug Workaround**: Due to the known `@modelcontextprotocol/sdk` bug, provide all query parameters when using dynamic queries
|
|
725
759
|
- **Parameter Validation**: Ensure query parameters match OData v4 syntax
|
|
726
760
|
|
|
727
761
|
#### Performance Issues
|
|
762
|
+
|
|
728
763
|
- **Resource Filtering**: Use specific `resource` arrays instead of `true` for large datasets
|
|
729
764
|
- **Query Optimization**: Implement proper database indexes for frequently queried fields
|
|
730
765
|
|
|
731
766
|
### Debugging
|
|
732
767
|
|
|
733
768
|
#### Enable Debug Logging
|
|
769
|
+
|
|
734
770
|
```json
|
|
735
771
|
{
|
|
736
772
|
"cds": {
|
|
@@ -744,6 +780,7 @@ curl http://localhost:4004/mcp/health
|
|
|
744
780
|
```
|
|
745
781
|
|
|
746
782
|
#### Test MCP Implementation
|
|
783
|
+
|
|
747
784
|
```bash
|
|
748
785
|
# Use MCP Inspector for interactive testing
|
|
749
786
|
npm run inspect
|
|
@@ -761,14 +798,17 @@ npm test -- --testPathPattern=integration
|
|
|
761
798
|
## 🚨 Performance & Limitations
|
|
762
799
|
|
|
763
800
|
### Known Limitations
|
|
801
|
+
|
|
764
802
|
- **SDK Bug**: Dynamic resource queries require all query parameters due to `@modelcontextprotocol/sdk` RFC template string issue
|
|
765
803
|
|
|
766
804
|
### Performance Considerations
|
|
805
|
+
|
|
767
806
|
- **Large Datasets**: Use `resource: ['top']` or similar constraints for entities with many records
|
|
768
807
|
- **Complex Queries**: OData query parsing adds overhead - consider caching for frequently accessed data
|
|
769
808
|
- **Concurrent Sessions**: Each MCP client creates a separate session - monitor memory usage with many clients
|
|
770
809
|
|
|
771
810
|
### Scale Recommendations
|
|
811
|
+
|
|
772
812
|
- **Development**: No specific limits
|
|
773
813
|
- **Production**: Test with expected concurrent MCP client count
|
|
774
814
|
- **Enterprise**: Consider load balancing for high-availability scenarios
|
|
@@ -781,6 +821,7 @@ npm test -- --testPathPattern=integration
|
|
|
781
821
|
- [MCP Inspector Tool](https://github.com/modelcontextprotocol/inspector)
|
|
782
822
|
|
|
783
823
|
---
|
|
824
|
+
|
|
784
825
|
(c) Copyright by Gavdi Labs 2025 - All Rights Reserved
|
|
785
826
|
|
|
786
827
|
**Transform your CAP applications into AI-ready systems with the power of the Model Context Protocol.**
|
|
@@ -22,6 +22,7 @@ exports.MCP_ANNOTATION_MAPPING = new Map([
|
|
|
22
22
|
["@mcp.wrap", "wrap"],
|
|
23
23
|
["@mcp.wrap.tools", "wrap.tools"],
|
|
24
24
|
["@mcp.wrap.modes", "wrap.modes"],
|
|
25
|
+
["@mcp.wrap.name", "wrap.name"],
|
|
25
26
|
["@mcp.wrap.hint", "wrap.hint"],
|
|
26
27
|
["@mcp.wrap.hint.get", "wrap.hint.get"],
|
|
27
28
|
["@mcp.wrap.hint.query", "wrap.hint.query"],
|
package/lib/auth/factory.js
CHANGED
|
@@ -5,10 +5,32 @@ exports.errorHandlerFactory = errorHandlerFactory;
|
|
|
5
5
|
const xsuaa_service_1 = require("./xsuaa-service");
|
|
6
6
|
const utils_1 = require("./utils");
|
|
7
7
|
const logger_1 = require("../logger");
|
|
8
|
+
const host_resolver_1 = require("./host-resolver");
|
|
8
9
|
/** JSON-RPC 2.0 error code for unauthorized requests */
|
|
9
10
|
const RPC_UNAUTHORIZED = 10;
|
|
10
11
|
/* @ts-ignore */
|
|
11
12
|
const cds = global.cds || require("@sap/cds"); // This is a work around for missing cds context
|
|
13
|
+
/**
|
|
14
|
+
* Sends a 401 response with RFC 9728 compliant WWW-Authenticate header.
|
|
15
|
+
* The header includes `resource_metadata` pointing to the protected resource metadata endpoint.
|
|
16
|
+
*
|
|
17
|
+
* @param req - Express request object
|
|
18
|
+
* @param res - Express response object
|
|
19
|
+
* @param message - Error message for the JSON-RPC response
|
|
20
|
+
*/
|
|
21
|
+
function send401WithMetadata(req, res, message) {
|
|
22
|
+
const baseUrl = (0, host_resolver_1.buildPublicBaseUrl)(req);
|
|
23
|
+
const metadataUrl = `${baseUrl}/.well-known/oauth-protected-resource`;
|
|
24
|
+
res.set("WWW-Authenticate", `Bearer resource_metadata="${metadataUrl}"`);
|
|
25
|
+
res.status(401).json({
|
|
26
|
+
jsonrpc: "2.0",
|
|
27
|
+
error: {
|
|
28
|
+
code: RPC_UNAUTHORIZED,
|
|
29
|
+
message,
|
|
30
|
+
id: null,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
12
34
|
/**
|
|
13
35
|
* Creates an Express middleware for MCP authentication validation.
|
|
14
36
|
*
|
|
@@ -39,14 +61,7 @@ function authHandlerFactory() {
|
|
|
39
61
|
logger_1.LOGGER.debug("Authentication kind", authKind);
|
|
40
62
|
return async (req, res, next) => {
|
|
41
63
|
if (!req.headers.authorization && authKind !== "dummy") {
|
|
42
|
-
res
|
|
43
|
-
jsonrpc: "2.0",
|
|
44
|
-
error: {
|
|
45
|
-
code: RPC_UNAUTHORIZED,
|
|
46
|
-
message: "Unauthorized",
|
|
47
|
-
id: null,
|
|
48
|
-
},
|
|
49
|
-
});
|
|
64
|
+
send401WithMetadata(req, res, "Unauthorized");
|
|
50
65
|
return;
|
|
51
66
|
}
|
|
52
67
|
// For XSUAA/JWT auth types, use @sap/xssec for validation
|
|
@@ -54,14 +69,7 @@ function authHandlerFactory() {
|
|
|
54
69
|
xsuaaService?.isConfigured()) {
|
|
55
70
|
const securityContext = await xsuaaService.createSecurityContext(req);
|
|
56
71
|
if (!securityContext) {
|
|
57
|
-
res
|
|
58
|
-
jsonrpc: "2.0",
|
|
59
|
-
error: {
|
|
60
|
-
code: RPC_UNAUTHORIZED,
|
|
61
|
-
message: "Invalid or expired token",
|
|
62
|
-
id: null,
|
|
63
|
-
},
|
|
64
|
-
});
|
|
72
|
+
send401WithMetadata(req, res, "Invalid or expired token");
|
|
65
73
|
return;
|
|
66
74
|
}
|
|
67
75
|
// Add security context to request for later use
|
|
@@ -82,14 +90,7 @@ function authHandlerFactory() {
|
|
|
82
90
|
}
|
|
83
91
|
const user = ctx.user;
|
|
84
92
|
if (!user || user === cds.User.anonymous) {
|
|
85
|
-
res
|
|
86
|
-
jsonrpc: "2.0",
|
|
87
|
-
error: {
|
|
88
|
-
code: RPC_UNAUTHORIZED,
|
|
89
|
-
message: "Unauthorized",
|
|
90
|
-
id: null,
|
|
91
|
-
},
|
|
92
|
-
});
|
|
93
|
+
send401WithMetadata(req, res, "Unauthorized");
|
|
93
94
|
return;
|
|
94
95
|
}
|
|
95
96
|
return next();
|
|
@@ -116,13 +117,17 @@ function authHandlerFactory() {
|
|
|
116
117
|
* @param next - Express next function for passing unhandled errors
|
|
117
118
|
*/
|
|
118
119
|
function errorHandlerFactory() {
|
|
119
|
-
return (err,
|
|
120
|
-
if (err === 401
|
|
121
|
-
res
|
|
120
|
+
return (err, req, res, next) => {
|
|
121
|
+
if (err === 401) {
|
|
122
|
+
send401WithMetadata(req, res, "Unauthorized");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (err === 403) {
|
|
126
|
+
res.status(403).json({
|
|
122
127
|
jsonrpc: "2.0",
|
|
123
128
|
error: {
|
|
124
129
|
code: RPC_UNAUTHORIZED,
|
|
125
|
-
message:
|
|
130
|
+
message: "Forbidden",
|
|
126
131
|
id: null,
|
|
127
132
|
},
|
|
128
133
|
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Host resolution for multi-tenant and reverse proxy scenarios.
|
|
4
|
+
*
|
|
5
|
+
* This module provides utilities to determine the correct public-facing host
|
|
6
|
+
* when the application runs behind SAP Approuter or other reverse proxies.
|
|
7
|
+
*
|
|
8
|
+
* Header Priority (based on SAP Approuter documentation):
|
|
9
|
+
* 1. x-forwarded-host - Standard header set by Approuter (setXForwardedHeaders=true, default)
|
|
10
|
+
* 2. x-custom-host - Only when EXTERNAL_REVERSE_PROXY=true
|
|
11
|
+
* 3. APP_DOMAIN env - Fallback for HTTP/2 bug where X-Forwarded headers may be missing
|
|
12
|
+
* 4. host header - Local development fallback
|
|
13
|
+
*
|
|
14
|
+
* @module host-resolver
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.getHostResolverEnv = getHostResolverEnv;
|
|
18
|
+
exports.isProductionEnv = isProductionEnv;
|
|
19
|
+
exports.extractSubdomain = extractSubdomain;
|
|
20
|
+
exports.normalizeHost = normalizeHost;
|
|
21
|
+
exports.resolveEffectiveHost = resolveEffectiveHost;
|
|
22
|
+
exports.getProtocol = getProtocol;
|
|
23
|
+
exports.buildPublicBaseUrl = buildPublicBaseUrl;
|
|
24
|
+
const logger_1 = require("../logger");
|
|
25
|
+
/**
|
|
26
|
+
* Reads environment configuration for host resolution.
|
|
27
|
+
* Separated for testability.
|
|
28
|
+
*/
|
|
29
|
+
function getHostResolverEnv() {
|
|
30
|
+
return {
|
|
31
|
+
appDomain: process.env.APP_DOMAIN,
|
|
32
|
+
externalReverseProxy: process.env.EXTERNAL_REVERSE_PROXY === "true",
|
|
33
|
+
nodeEnv: process.env.NODE_ENV,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Determines if the environment is production.
|
|
38
|
+
* Production = NODE_ENV explicitly set to "production".
|
|
39
|
+
*/
|
|
40
|
+
function isProductionEnv(env) {
|
|
41
|
+
return env.nodeEnv === "production";
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Extracts subdomain from a hostname.
|
|
45
|
+
* For "tenant1.myapp.cloud", returns "tenant1".
|
|
46
|
+
* For "myapp.cloud" or "localhost", returns "".
|
|
47
|
+
*/
|
|
48
|
+
function extractSubdomain(host, appDomain) {
|
|
49
|
+
if (!host || !appDomain)
|
|
50
|
+
return "";
|
|
51
|
+
// Normalize: remove port, lowercase
|
|
52
|
+
const cleanHost = host.split(":")[0].toLowerCase();
|
|
53
|
+
const cleanDomain = appDomain.toLowerCase();
|
|
54
|
+
if (cleanHost.endsWith(`.${cleanDomain}`)) {
|
|
55
|
+
return cleanHost.slice(0, -(cleanDomain.length + 1));
|
|
56
|
+
}
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Safely gets the host header from request.
|
|
61
|
+
* Handles cases where req.get might not be available (e.g., in tests).
|
|
62
|
+
*/
|
|
63
|
+
function getHostHeader(req) {
|
|
64
|
+
if (typeof req.get === "function") {
|
|
65
|
+
return req.get("host") || "";
|
|
66
|
+
}
|
|
67
|
+
return req.headers?.host || "";
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Normalizes a host header value.
|
|
71
|
+
* - Takes first value if comma-separated (proxy chain)
|
|
72
|
+
* - Trims whitespace
|
|
73
|
+
*/
|
|
74
|
+
function normalizeHost(headerValue) {
|
|
75
|
+
if (!headerValue)
|
|
76
|
+
return "";
|
|
77
|
+
// Take first host if comma-separated (multiple proxies)
|
|
78
|
+
return headerValue.split(",")[0].trim();
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Resolves the effective public-facing host for the current request.
|
|
82
|
+
*
|
|
83
|
+
* @param req - Express request object
|
|
84
|
+
* @param env - Environment configuration (optional, uses process.env by default)
|
|
85
|
+
* @returns The resolved public host (without protocol)
|
|
86
|
+
*/
|
|
87
|
+
function resolveEffectiveHost(req, env = getHostResolverEnv()) {
|
|
88
|
+
const isProduction = isProductionEnv(env);
|
|
89
|
+
// Priority 1: x-forwarded-host (SAP Approuter standard)
|
|
90
|
+
const xfh = normalizeHost(req.headers["x-forwarded-host"]);
|
|
91
|
+
if (xfh) {
|
|
92
|
+
logger_1.LOGGER.debug("[host-resolver] using x-forwarded-host", { host: xfh });
|
|
93
|
+
return xfh;
|
|
94
|
+
}
|
|
95
|
+
// Priority 2: x-custom-host (only with EXTERNAL_REVERSE_PROXY)
|
|
96
|
+
if (env.externalReverseProxy) {
|
|
97
|
+
const xch = normalizeHost(req.headers["x-custom-host"]);
|
|
98
|
+
if (xch) {
|
|
99
|
+
logger_1.LOGGER.debug("[host-resolver] using x-custom-host", { host: xch });
|
|
100
|
+
return xch;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Priority 3: APP_DOMAIN fallback (production only)
|
|
104
|
+
if (env.appDomain && isProduction) {
|
|
105
|
+
logger_1.LOGGER.info("[host-resolver] using APP_DOMAIN fallback", {
|
|
106
|
+
host: env.appDomain,
|
|
107
|
+
});
|
|
108
|
+
return env.appDomain;
|
|
109
|
+
}
|
|
110
|
+
// Priority 4: Raw host header
|
|
111
|
+
const host = normalizeHost(getHostHeader(req)) || "localhost";
|
|
112
|
+
if (isProduction) {
|
|
113
|
+
logger_1.LOGGER.warn("[host-resolver] using raw host in production", { host });
|
|
114
|
+
}
|
|
115
|
+
return host;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Determines the protocol (http/https) for URL construction.
|
|
119
|
+
*
|
|
120
|
+
* @param req - Express request object
|
|
121
|
+
* @param env - Environment configuration (optional)
|
|
122
|
+
* @returns Protocol string ('http' or 'https')
|
|
123
|
+
*/
|
|
124
|
+
function getProtocol(req, env = getHostResolverEnv()) {
|
|
125
|
+
// Check x-forwarded-proto first (most reliable behind proxy)
|
|
126
|
+
const forwardedProto = req.headers["x-forwarded-proto"];
|
|
127
|
+
if (forwardedProto) {
|
|
128
|
+
// Take first value if comma-separated
|
|
129
|
+
return forwardedProto.split(",")[0].trim();
|
|
130
|
+
}
|
|
131
|
+
// Default to HTTPS in production
|
|
132
|
+
return isProductionEnv(env) ? "https" : req.protocol;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Builds the complete public base URL for the current request.
|
|
136
|
+
* Combines protocol and resolved host.
|
|
137
|
+
*
|
|
138
|
+
* @param req - Express request object
|
|
139
|
+
* @param env - Environment configuration (optional)
|
|
140
|
+
* @returns Full base URL (e.g., "https://tenant1.myapp.cloud")
|
|
141
|
+
*/
|
|
142
|
+
function buildPublicBaseUrl(req, env = getHostResolverEnv()) {
|
|
143
|
+
const protocol = getProtocol(req, env);
|
|
144
|
+
const host = resolveEffectiveHost(req, env);
|
|
145
|
+
return `${protocol}://${host}`;
|
|
146
|
+
}
|
package/lib/auth/utils.js
CHANGED
|
@@ -15,6 +15,7 @@ const factory_1 = require("./factory");
|
|
|
15
15
|
const xsuaa_service_1 = require("./xsuaa-service");
|
|
16
16
|
const handlers_1 = require("./handlers");
|
|
17
17
|
const logger_1 = require("../logger");
|
|
18
|
+
const host_resolver_1 = require("./host-resolver");
|
|
18
19
|
/**
|
|
19
20
|
* @fileoverview Authentication utilities for MCP-CAP integration.
|
|
20
21
|
*
|
|
@@ -194,22 +195,6 @@ function configureOAuthProxy(expressApp) {
|
|
|
194
195
|
}
|
|
195
196
|
registerOAuthEndpoints(expressApp, credentials, kind);
|
|
196
197
|
}
|
|
197
|
-
/**
|
|
198
|
-
* Determines the correct protocol (HTTP/HTTPS) for URL construction.
|
|
199
|
-
* Accounts for reverse proxy headers and production environment defaults.
|
|
200
|
-
*
|
|
201
|
-
* @param req - Express request object
|
|
202
|
-
* @returns Protocol string ('http' or 'https')
|
|
203
|
-
*/
|
|
204
|
-
function getProtocol(req) {
|
|
205
|
-
// Check for reverse proxy header first (most reliable)
|
|
206
|
-
if (req.headers["x-forwarded-proto"]) {
|
|
207
|
-
return req.headers["x-forwarded-proto"];
|
|
208
|
-
}
|
|
209
|
-
// Default to HTTPS in production environments
|
|
210
|
-
const isProduction = process.env.NODE_ENV === "production" || process.env.VCAP_APPLICATION;
|
|
211
|
-
return isProduction ? "https" : req.protocol;
|
|
212
|
-
}
|
|
213
198
|
/**
|
|
214
199
|
* Registers OAuth endpoints for XSUAA integration
|
|
215
200
|
* Only called for jwt/xsuaa/ias auth types with valid credentials
|
|
@@ -237,8 +222,8 @@ function registerOAuthEndpoints(expressApp, credentials, kind) {
|
|
|
237
222
|
const { state, redirect_uri, client_id, code_challenge, code_challenge_method, scope, } = req.query;
|
|
238
223
|
// Client validation and redirect URI validation is handled by XSUAA
|
|
239
224
|
// We delegate all client management to XSUAA's built-in OAuth server
|
|
240
|
-
const
|
|
241
|
-
const redirectUri = redirect_uri || `${
|
|
225
|
+
const baseUrl = (0, host_resolver_1.buildPublicBaseUrl)(req);
|
|
226
|
+
const redirectUri = redirect_uri || `${baseUrl}/oauth/callback`;
|
|
242
227
|
const authUrl = xsuaaService.getAuthorizationUrl(redirectUri, client_id ?? "", state, code_challenge, code_challenge_method, scope);
|
|
243
228
|
res.redirect(authUrl);
|
|
244
229
|
});
|
|
@@ -261,8 +246,8 @@ function registerOAuthEndpoints(expressApp, credentials, kind) {
|
|
|
261
246
|
return;
|
|
262
247
|
}
|
|
263
248
|
try {
|
|
264
|
-
const
|
|
265
|
-
const url = redirect_uri || `${
|
|
249
|
+
const baseUrl = (0, host_resolver_1.buildPublicBaseUrl)(req);
|
|
250
|
+
const url = redirect_uri || `${baseUrl}/oauth/callback`;
|
|
266
251
|
const tokenData = await xsuaaService.exchangeCodeForToken(code, url, code_verifier);
|
|
267
252
|
const scopedToken = await xsuaaService.getApplicationScopes(tokenData);
|
|
268
253
|
logger_1.LOGGER.debug("Scopes in token:", scopedToken.scope);
|
|
@@ -283,8 +268,7 @@ function registerOAuthEndpoints(expressApp, credentials, kind) {
|
|
|
283
268
|
});
|
|
284
269
|
// OAuth Discovery endpoint
|
|
285
270
|
expressApp.get("/.well-known/oauth-authorization-server", (req, res) => {
|
|
286
|
-
const
|
|
287
|
-
const baseUrl = `${protocol}://${req.get("host")}`;
|
|
271
|
+
const baseUrl = (0, host_resolver_1.buildPublicBaseUrl)(req);
|
|
288
272
|
res.json({
|
|
289
273
|
issuer: credentials.url,
|
|
290
274
|
authorization_endpoint: `${baseUrl}/oauth/authorize`,
|
|
@@ -298,15 +282,25 @@ function registerOAuthEndpoints(expressApp, credentials, kind) {
|
|
|
298
282
|
registration_endpoint_auth_methods_supported: ["client_secret_basic"],
|
|
299
283
|
});
|
|
300
284
|
});
|
|
285
|
+
// RFC 9728: OAuth 2.0 Protected Resource Metadata endpoint
|
|
286
|
+
expressApp.get("/.well-known/oauth-protected-resource", (req, res) => {
|
|
287
|
+
const baseUrl = (0, host_resolver_1.buildPublicBaseUrl)(req);
|
|
288
|
+
res.json({
|
|
289
|
+
resource: baseUrl,
|
|
290
|
+
authorization_servers: [credentials.url],
|
|
291
|
+
bearer_methods_supported: ["header"],
|
|
292
|
+
resource_documentation: `${baseUrl}/mcp/health`,
|
|
293
|
+
});
|
|
294
|
+
});
|
|
301
295
|
// OAuth Dynamic Client Registration discovery endpoint (GET)
|
|
302
296
|
expressApp.get("/oauth/register", async (req, res) => {
|
|
297
|
+
const baseUrl = (0, host_resolver_1.buildPublicBaseUrl)(req);
|
|
303
298
|
// IAS does not support DCR so we will respond with the pre-configured client_id
|
|
304
299
|
if (kind === "ias") {
|
|
305
|
-
const protocol = getProtocol(req);
|
|
306
300
|
const enhancedResponse = {
|
|
307
301
|
client_id: credentials.clientid, // Add our CAP app's client ID
|
|
308
302
|
redirect_uris: req.body.redirect_uris || [
|
|
309
|
-
`${
|
|
303
|
+
`${baseUrl}/oauth/callback`,
|
|
310
304
|
],
|
|
311
305
|
};
|
|
312
306
|
logger_1.LOGGER.debug("Provided static client_id during DCR registration process");
|
|
@@ -325,11 +319,10 @@ function registerOAuthEndpoints(expressApp, credentials, kind) {
|
|
|
325
319
|
});
|
|
326
320
|
const xsuaaData = await response.json();
|
|
327
321
|
// Add missing required fields that MCP client expects
|
|
328
|
-
const protocol = getProtocol(req);
|
|
329
322
|
const enhancedResponse = {
|
|
330
323
|
...xsuaaData, // Keep all XSUAA fields
|
|
331
324
|
client_id: credentials.clientid, // Add our CAP app's client ID
|
|
332
|
-
redirect_uris: [`${
|
|
325
|
+
redirect_uris: [`${baseUrl}/oauth/callback`], // Add our callback URL for discovery
|
|
333
326
|
};
|
|
334
327
|
res.status(response.status).json(enhancedResponse);
|
|
335
328
|
}
|
|
@@ -343,13 +336,13 @@ function registerOAuthEndpoints(expressApp, credentials, kind) {
|
|
|
343
336
|
});
|
|
344
337
|
// OAuth Dynamic Client Registration endpoint (POST) with CSRF handling
|
|
345
338
|
expressApp.post("/oauth/register", async (req, res) => {
|
|
339
|
+
const baseUrl = (0, host_resolver_1.buildPublicBaseUrl)(req);
|
|
346
340
|
// IAS does not support DCR so we will respond with the pre-configured client_id
|
|
347
341
|
if (kind === "ias") {
|
|
348
|
-
const protocol = getProtocol(req);
|
|
349
342
|
const enhancedResponse = {
|
|
350
343
|
client_id: credentials.clientid, // Add our CAP app's client ID
|
|
351
344
|
redirect_uris: req.body.redirect_uris || [
|
|
352
|
-
`${
|
|
345
|
+
`${baseUrl}/oauth/callback`,
|
|
353
346
|
],
|
|
354
347
|
};
|
|
355
348
|
logger_1.LOGGER.debug("Provided static client_id during DCR registration process");
|
|
@@ -390,14 +383,12 @@ function registerOAuthEndpoints(expressApp, credentials, kind) {
|
|
|
390
383
|
});
|
|
391
384
|
const xsuaaData = await registrationResponse.json();
|
|
392
385
|
// Add missing required fields that MCP client expects
|
|
393
|
-
const protocol = getProtocol(req);
|
|
394
386
|
const enhancedResponse = {
|
|
395
387
|
...xsuaaData, // Keep all XSUAA fields
|
|
396
388
|
client_id: credentials.clientid, // Add our CAP app's client ID
|
|
397
|
-
// client_secret: credentials.clientsecret, // CAP app's client secret
|
|
398
389
|
redirect_uris: req.body.redirect_uris || [
|
|
399
|
-
`${
|
|
400
|
-
],
|
|
390
|
+
`${baseUrl}/oauth/callback`,
|
|
391
|
+
],
|
|
401
392
|
};
|
|
402
393
|
logger_1.LOGGER.debug("[AUTH] Register POST response", enhancedResponse);
|
|
403
394
|
res.status(registrationResponse.status).json(enhancedResponse);
|
package/lib/config/loader.js
CHANGED
package/lib/mcp/entity-tools.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.coerceKeyValue = coerceKeyValue;
|
|
3
4
|
exports.registerEntityWrappers = registerEntityWrappers;
|
|
4
5
|
const zod_1 = require("zod");
|
|
5
6
|
const utils_1 = require("../auth/utils");
|
|
@@ -61,6 +62,90 @@ async function resolveServiceInstance(serviceName) {
|
|
|
61
62
|
// NOTE: We use plain entity names (service projection) for queries.
|
|
62
63
|
const MAX_TOP = 200;
|
|
63
64
|
const TIMEOUT_MS = 10_000; // Standard timeout for tool calls (ms)
|
|
65
|
+
/**
|
|
66
|
+
* CDS integer types whose values can be safely represented as a JavaScript `Number`
|
|
67
|
+
* without risk of precision loss: `Integer`, `Int16`, `Int32`, `UInt8`.
|
|
68
|
+
*
|
|
69
|
+
* All values of these types are guaranteed to fit within `Number.MAX_SAFE_INTEGER`
|
|
70
|
+
* (2^53 - 1). When a key value arrives as a digit string (e.g. `"42"`),
|
|
71
|
+
* {@link coerceKeyValue} converts it to a `Number` so that CAP can resolve
|
|
72
|
+
* the entity lookup correctly.
|
|
73
|
+
*
|
|
74
|
+
* The following numeric CDS types are intentionally excluded:
|
|
75
|
+
*
|
|
76
|
+
* - `Int64` — values may exceed `Number.MAX_SAFE_INTEGER`, causing silent
|
|
77
|
+
* precision loss. Classified under {@link PRECISION_SENSITIVE_CDS_TYPES}.
|
|
78
|
+
* - `Decimal` — arbitrary-precision type; CAP preserves these as strings
|
|
79
|
+
* for exact arithmetic. Classified under {@link PRECISION_SENSITIVE_CDS_TYPES}.
|
|
80
|
+
* - `Double` — maps directly to IEEE 754 double-precision (identical to JS
|
|
81
|
+
* `number`), so there is no precision concern. Excluded here because it is
|
|
82
|
+
* a floating-point type, not an integer.
|
|
83
|
+
*
|
|
84
|
+
* @see {@link PRECISION_SENSITIVE_CDS_TYPES}
|
|
85
|
+
* @see {@link coerceKeyValue}
|
|
86
|
+
* @see https://tc39.es/ecma262/#sec-number.max_safe_integer — ECMAScript Number.MAX_SAFE_INTEGER
|
|
87
|
+
* @see https://cap.cloud.sap/docs/cds/types — CAP CDS Built-in Types
|
|
88
|
+
*/
|
|
89
|
+
const SAFE_INTEGER_CDS_TYPES = new Set(["Integer", "Int16", "Int32", "UInt8"]);
|
|
90
|
+
/**
|
|
91
|
+
* CDS numeric types where values must be represented as strings to preserve
|
|
92
|
+
* precision: `Int64`, `Decimal`.
|
|
93
|
+
*
|
|
94
|
+
* JavaScript `Number` (IEEE 754 double-precision) cannot faithfully represent
|
|
95
|
+
* all values of these types. When a key value arrives as a `number` from the
|
|
96
|
+
* MCP tool input, {@link coerceKeyValue} normalizes it to a `string` so that
|
|
97
|
+
* CAP can handle it without silent truncation.
|
|
98
|
+
*
|
|
99
|
+
* CAP's `cds.features.ieee754compatible` setting controls whether OData
|
|
100
|
+
* responses serialize `Edm.Int64` and `Edm.Decimal` as JSON strings.
|
|
101
|
+
* Regardless of that setting, this set ensures keys are always passed as
|
|
102
|
+
* strings to the CAP runtime.
|
|
103
|
+
*
|
|
104
|
+
* `Double` is intentionally not included. It maps directly to IEEE 754
|
|
105
|
+
* double-precision — the same representation as a JavaScript `number` — so
|
|
106
|
+
* no precision is lost during coercion. The `ieee754compatible` flag does
|
|
107
|
+
* not affect `Double`.
|
|
108
|
+
*
|
|
109
|
+
* @see {@link SAFE_INTEGER_CDS_TYPES}
|
|
110
|
+
* @see {@link coerceKeyValue}
|
|
111
|
+
* @see https://www.rfc-editor.org/rfc/rfc8259#section-6 — RFC 8259, Section 6: Numbers
|
|
112
|
+
* @see https://cap.cloud.sap/docs/releases/jun24#ieee754compatible — CAP ieee754compatible
|
|
113
|
+
*/
|
|
114
|
+
const PRECISION_SENSITIVE_CDS_TYPES = new Set(["Int64", "Decimal"]);
|
|
115
|
+
/**
|
|
116
|
+
* Coerces a key value between string and number representations based on
|
|
117
|
+
* the CDS type metadata of the target entity element.
|
|
118
|
+
*
|
|
119
|
+
* Coercion rules, applied in order:
|
|
120
|
+
*
|
|
121
|
+
* 1. Safe integer types ({@link SAFE_INTEGER_CDS_TYPES}) — digit-only strings
|
|
122
|
+
* (e.g. `"42"`) are converted to `Number`. `UInt8` only accepts non-negative
|
|
123
|
+
* digit strings since it is an unsigned type.
|
|
124
|
+
* 2. Precision-sensitive types ({@link PRECISION_SENSITIVE_CDS_TYPES}) — numeric
|
|
125
|
+
* values are converted to `String` to prevent silent precision loss beyond
|
|
126
|
+
* `Number.MAX_SAFE_INTEGER`.
|
|
127
|
+
* 3. All other types — returned unchanged.
|
|
128
|
+
*
|
|
129
|
+
* @param raw - The raw key value received from the MCP tool input.
|
|
130
|
+
* @param cdsType - The CDS type name of the key element (e.g. `"String"`, `"Integer"`).
|
|
131
|
+
* @returns The coerced value, or the original value if no coercion rule applies.
|
|
132
|
+
*
|
|
133
|
+
* @see {@link SAFE_INTEGER_CDS_TYPES}
|
|
134
|
+
* @see {@link PRECISION_SENSITIVE_CDS_TYPES}
|
|
135
|
+
*/
|
|
136
|
+
function coerceKeyValue(raw, cdsType) {
|
|
137
|
+
if (typeof raw === "string" && SAFE_INTEGER_CDS_TYPES.has(cdsType)) {
|
|
138
|
+
// UInt8 is unsigned — only accept non-negative digit strings
|
|
139
|
+
const pattern = cdsType === "UInt8" ? /^\d+$/ : /^-?\d+$/;
|
|
140
|
+
if (pattern.test(raw)) {
|
|
141
|
+
return Number(raw);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (typeof raw === "number" && PRECISION_SENSITIVE_CDS_TYPES.has(cdsType)) {
|
|
145
|
+
return String(raw);
|
|
146
|
+
}
|
|
147
|
+
return raw;
|
|
148
|
+
}
|
|
64
149
|
// Map OData operators to CDS/SQL operators for better performance and readability
|
|
65
150
|
const ODATA_TO_CDS_OPERATORS = new Map([
|
|
66
151
|
["eq", "="],
|
|
@@ -123,8 +208,13 @@ function registerEntityWrappers(resAnno, server, authEnabled, defaultModes, acce
|
|
|
123
208
|
* Builds the visible tool name for a given operation mode.
|
|
124
209
|
* We prefer a descriptive naming scheme that is easy for humans and LLMs:
|
|
125
210
|
* Service_Entity_mode
|
|
211
|
+
* If customName is provided via @mcp.wrap.name, uses customName_mode instead.
|
|
126
212
|
*/
|
|
127
|
-
function nameFor(service, entity, suffix) {
|
|
213
|
+
function nameFor(service, entity, suffix, customName) {
|
|
214
|
+
// Custom name takes priority - uses format: customName_suffix
|
|
215
|
+
if (customName) {
|
|
216
|
+
return `${customName}_${suffix}`;
|
|
217
|
+
}
|
|
128
218
|
// Use explicit Service_Entity_suffix naming to match docs/tests
|
|
129
219
|
const entityName = entity.split(".").pop(); // keep original case
|
|
130
220
|
const serviceName = service.split(".").pop(); // keep original case
|
|
@@ -135,7 +225,7 @@ function nameFor(service, entity, suffix) {
|
|
|
135
225
|
* Supports select/where/orderby/top/skip and simple text search (q).
|
|
136
226
|
*/
|
|
137
227
|
function registerQueryTool(resAnno, server, authEnabled) {
|
|
138
|
-
const toolName = nameFor(resAnno.serviceName, resAnno.target, "query");
|
|
228
|
+
const toolName = nameFor(resAnno.serviceName, resAnno.target, "query", resAnno.wrap?.name);
|
|
139
229
|
// Structured input schema for queries with guard for empty property lists
|
|
140
230
|
const allKeys = Array.from(resAnno.properties.keys());
|
|
141
231
|
const scalarKeys = Array.from(resAnno.properties.entries())
|
|
@@ -275,7 +365,7 @@ function registerQueryTool(resAnno, server, authEnabled) {
|
|
|
275
365
|
* Accepts keys either as an object or shorthand (single-key) value.
|
|
276
366
|
*/
|
|
277
367
|
function registerGetTool(resAnno, server, authEnabled) {
|
|
278
|
-
const toolName = nameFor(resAnno.serviceName, resAnno.target, "get");
|
|
368
|
+
const toolName = nameFor(resAnno.serviceName, resAnno.target, "get", resAnno.wrap?.name);
|
|
279
369
|
const inputSchema = {};
|
|
280
370
|
for (const [k, cdsType] of resAnno.resourceKeys.entries()) {
|
|
281
371
|
inputSchema[k] = (0, utils_2.determineMcpParameterType)(cdsType).describe(`Key ${k}. ${resAnno.propertyHints.get(k) ?? ""}`);
|
|
@@ -313,7 +403,7 @@ function registerGetTool(resAnno, server, authEnabled) {
|
|
|
313
403
|
}
|
|
314
404
|
}
|
|
315
405
|
const keys = {};
|
|
316
|
-
for (const [k] of resAnno.resourceKeys.entries()) {
|
|
406
|
+
for (const [k, cdsType] of resAnno.resourceKeys.entries()) {
|
|
317
407
|
let provided = normalizedArgs[k];
|
|
318
408
|
if (provided === undefined) {
|
|
319
409
|
const alt = Object.entries(normalizedArgs || {}).find(([kk]) => String(kk).toLowerCase() === String(k).toLowerCase());
|
|
@@ -324,9 +414,7 @@ function registerGetTool(resAnno, server, authEnabled) {
|
|
|
324
414
|
logger_1.LOGGER.warn(`Get tool missing required key`, { key: k, toolName });
|
|
325
415
|
return (0, utils_2.toolError)("MISSING_KEY", `Missing key '${k}'`);
|
|
326
416
|
}
|
|
327
|
-
|
|
328
|
-
keys[k] =
|
|
329
|
-
typeof raw === "string" && /^\d+$/.test(raw) ? Number(raw) : raw;
|
|
417
|
+
keys[k] = coerceKeyValue(provided, cdsType);
|
|
330
418
|
}
|
|
331
419
|
logger_1.LOGGER.debug(`Executing READ on ${resAnno.target} with keys`, keys);
|
|
332
420
|
try {
|
|
@@ -348,7 +436,7 @@ function registerGetTool(resAnno, server, authEnabled) {
|
|
|
348
436
|
* Associations are exposed via <assoc>_ID fields for simplicity.
|
|
349
437
|
*/
|
|
350
438
|
function registerCreateTool(resAnno, server, authEnabled) {
|
|
351
|
-
const toolName = nameFor(resAnno.serviceName, resAnno.target, "create");
|
|
439
|
+
const toolName = nameFor(resAnno.serviceName, resAnno.target, "create", resAnno.wrap?.name);
|
|
352
440
|
const inputSchema = {};
|
|
353
441
|
for (const [propName, cdsType] of resAnno.properties.entries()) {
|
|
354
442
|
const isAssociation = String(cdsType).toLowerCase().includes("association");
|
|
@@ -447,7 +535,7 @@ function registerCreateTool(resAnno, server, authEnabled) {
|
|
|
447
535
|
* Keys are required; non-key fields are optional. Associations via <assoc>_ID.
|
|
448
536
|
*/
|
|
449
537
|
function registerUpdateTool(resAnno, server, authEnabled) {
|
|
450
|
-
const toolName = nameFor(resAnno.serviceName, resAnno.target, "update");
|
|
538
|
+
const toolName = nameFor(resAnno.serviceName, resAnno.target, "update", resAnno.wrap?.name);
|
|
451
539
|
const inputSchema = {};
|
|
452
540
|
// Keys required
|
|
453
541
|
for (const [k, cdsType] of resAnno.resourceKeys.entries()) {
|
|
@@ -494,14 +582,11 @@ function registerUpdateTool(resAnno, server, authEnabled) {
|
|
|
494
582
|
}
|
|
495
583
|
// Extract keys and update fields
|
|
496
584
|
const keys = {};
|
|
497
|
-
for (const [k] of resAnno.resourceKeys.entries()) {
|
|
585
|
+
for (const [k, cdsType] of resAnno.resourceKeys.entries()) {
|
|
498
586
|
if (args[k] === undefined) {
|
|
499
|
-
return {
|
|
500
|
-
isError: true,
|
|
501
|
-
content: [{ type: "text", text: `Missing key '${k}'` }],
|
|
502
|
-
};
|
|
587
|
+
return (0, utils_2.toolError)("MISSING_KEY", `Missing key '${k}'`);
|
|
503
588
|
}
|
|
504
|
-
keys[k] = args[k];
|
|
589
|
+
keys[k] = coerceKeyValue(args[k], cdsType);
|
|
505
590
|
}
|
|
506
591
|
// Normalize updates: prefer *_ID for associations and coerce numeric strings
|
|
507
592
|
const updates = {};
|
|
@@ -569,7 +654,7 @@ function registerUpdateTool(resAnno, server, authEnabled) {
|
|
|
569
654
|
* Requires keys to identify the entity to delete.
|
|
570
655
|
*/
|
|
571
656
|
function registerDeleteTool(resAnno, server, authEnabled) {
|
|
572
|
-
const toolName = nameFor(resAnno.serviceName, resAnno.target, "delete");
|
|
657
|
+
const toolName = nameFor(resAnno.serviceName, resAnno.target, "delete", resAnno.wrap?.name);
|
|
573
658
|
const inputSchema = {};
|
|
574
659
|
// Keys required for deletion
|
|
575
660
|
for (const [k, cdsType] of resAnno.resourceKeys.entries()) {
|
|
@@ -589,7 +674,7 @@ function registerDeleteTool(resAnno, server, authEnabled) {
|
|
|
589
674
|
}
|
|
590
675
|
// Extract keys - similar to get/update handlers
|
|
591
676
|
const keys = {};
|
|
592
|
-
for (const [k] of resAnno.resourceKeys.entries()) {
|
|
677
|
+
for (const [k, cdsType] of resAnno.resourceKeys.entries()) {
|
|
593
678
|
let provided = args[k];
|
|
594
679
|
if (provided === undefined) {
|
|
595
680
|
// Case-insensitive key matching (like in get handler)
|
|
@@ -601,10 +686,7 @@ function registerDeleteTool(resAnno, server, authEnabled) {
|
|
|
601
686
|
logger_1.LOGGER.warn(`Delete tool missing required key`, { key: k, toolName });
|
|
602
687
|
return (0, utils_2.toolError)("MISSING_KEY", `Missing key '${k}'`);
|
|
603
688
|
}
|
|
604
|
-
|
|
605
|
-
const raw = provided;
|
|
606
|
-
keys[k] =
|
|
607
|
-
typeof raw === "string" && /^\d+$/.test(raw) ? Number(raw) : raw;
|
|
689
|
+
keys[k] = coerceKeyValue(provided, cdsType);
|
|
608
690
|
}
|
|
609
691
|
logger_1.LOGGER.debug(`Executing DELETE on ${resAnno.target} with keys`, keys);
|
|
610
692
|
const tx = svc.tx({ user: (0, utils_1.getAccessRights)(authEnabled) });
|
package/lib/mcp/factory.js
CHANGED
|
@@ -34,8 +34,10 @@ function createMcpServer(config, annotations) {
|
|
|
34
34
|
}
|
|
35
35
|
logger_1.LOGGER.debug("Annotations found for server: ", annotations);
|
|
36
36
|
const authEnabled = (0, utils_1.isAuthEnabled)(config.auth);
|
|
37
|
-
// Always register discovery tool for better model planning
|
|
38
|
-
(
|
|
37
|
+
// Always register discovery tool for better model planning unless specifically turned off in configuration
|
|
38
|
+
if (config.enable_model_description) {
|
|
39
|
+
(0, describe_model_1.registerDescribeModelTool)(server);
|
|
40
|
+
}
|
|
39
41
|
const accessRights = (0, utils_1.getAccessRights)(authEnabled);
|
|
40
42
|
for (const entry of annotations.values()) {
|
|
41
43
|
if (entry instanceof structures_1.McpToolAnnotation) {
|
package/lib/mcp/utils.js
CHANGED
|
@@ -31,17 +31,17 @@ function determineMcpParameterType(cdsType, key, target) {
|
|
|
31
31
|
case "Timestamp":
|
|
32
32
|
return zod_1.z.coerce.date();
|
|
33
33
|
case "Integer":
|
|
34
|
-
return zod_1.z.number();
|
|
34
|
+
return zod_1.z.number().int();
|
|
35
35
|
case "Int16":
|
|
36
|
-
return zod_1.z.number();
|
|
36
|
+
return zod_1.z.number().int();
|
|
37
37
|
case "Int32":
|
|
38
|
-
return zod_1.z.number();
|
|
38
|
+
return zod_1.z.number().int();
|
|
39
39
|
case "Int64":
|
|
40
|
-
return zod_1.z.number();
|
|
40
|
+
return zod_1.z.union([zod_1.z.string(), zod_1.z.number().int()]).transform(String);
|
|
41
41
|
case "UInt8":
|
|
42
|
-
return zod_1.z.number();
|
|
42
|
+
return zod_1.z.number().int().min(0);
|
|
43
43
|
case "Decimal":
|
|
44
|
-
return zod_1.z.number();
|
|
44
|
+
return zod_1.z.union([zod_1.z.string(), zod_1.z.number()]).transform(String);
|
|
45
45
|
case "Double":
|
|
46
46
|
return zod_1.z.number();
|
|
47
47
|
case "Boolean":
|
|
@@ -67,17 +67,17 @@ function determineMcpParameterType(cdsType, key, target) {
|
|
|
67
67
|
case "UUIDArray":
|
|
68
68
|
return zod_1.z.array(zod_1.z.string());
|
|
69
69
|
case "IntegerArray":
|
|
70
|
-
return zod_1.z.array(zod_1.z.number());
|
|
70
|
+
return zod_1.z.array(zod_1.z.number().int());
|
|
71
71
|
case "Int16Array":
|
|
72
|
-
return zod_1.z.array(zod_1.z.number());
|
|
72
|
+
return zod_1.z.array(zod_1.z.number().int());
|
|
73
73
|
case "Int32Array":
|
|
74
|
-
return zod_1.z.array(zod_1.z.number());
|
|
74
|
+
return zod_1.z.array(zod_1.z.number().int());
|
|
75
75
|
case "Int64Array":
|
|
76
|
-
return zod_1.z.array(zod_1.z.number());
|
|
76
|
+
return zod_1.z.array(zod_1.z.union([zod_1.z.string(), zod_1.z.number().int()]).transform(String));
|
|
77
77
|
case "UInt8Array":
|
|
78
|
-
return zod_1.z.array(zod_1.z.number());
|
|
78
|
+
return zod_1.z.array(zod_1.z.number().int().min(0));
|
|
79
79
|
case "DecimalArray":
|
|
80
|
-
return zod_1.z.array(zod_1.z.number());
|
|
80
|
+
return zod_1.z.array(zod_1.z.union([zod_1.z.string(), zod_1.z.number()]).transform(String));
|
|
81
81
|
case "BooleanArray":
|
|
82
82
|
return zod_1.z.array(zod_1.z.boolean());
|
|
83
83
|
case "DoubleArray":
|