@sidurijs/hands 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/README.md +35 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +413 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +407 -0
- package/dist/life-tools.d.ts +9 -0
- package/dist/life-tools.js +216 -0
- package/dist/mcp-integration.test.d.ts +1 -0
- package/dist/mcp-integration.test.js +235 -0
- package/dist/mcp-provider.d.ts +33 -0
- package/dist/mcp-provider.js +131 -0
- package/dist/schema-validator.d.ts +5 -0
- package/dist/schema-validator.js +138 -0
- package/organ-manifest.json +56 -0
- package/package.json +49 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
|
|
4
|
+
const inMemory_js_1 = require("@modelcontextprotocol/sdk/inMemory.js");
|
|
5
|
+
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
|
|
6
|
+
const core_1 = require("@sidurijs/core");
|
|
7
|
+
const index_1 = require("./index");
|
|
8
|
+
describe('Hands Organ - Full MCP Protocol Integration Suite', () => {
|
|
9
|
+
const secretKey = 'test_mcp_hands_secret';
|
|
10
|
+
let engine;
|
|
11
|
+
let sampleContext;
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
engine = new core_1.ActionPolicyEngine({
|
|
14
|
+
secretKey,
|
|
15
|
+
defaultRiskLevel: 'LOW',
|
|
16
|
+
});
|
|
17
|
+
sampleContext = {
|
|
18
|
+
companionId: 'comp-mcp-1',
|
|
19
|
+
actor: {
|
|
20
|
+
actorId: 'user-mcp-1',
|
|
21
|
+
sessionId: 'sess-mcp-1',
|
|
22
|
+
authorizationRole: 'operator',
|
|
23
|
+
capabilities: ['tool:mcp_server/echo', 'tool:mcp_server/calculator', 'tool:failing_tool', 'tool:slow_tool'],
|
|
24
|
+
authenticated: true,
|
|
25
|
+
},
|
|
26
|
+
conversation: {
|
|
27
|
+
channel: 'direct',
|
|
28
|
+
correlationId: 'corr-mcp-1',
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
test('dynamically discovers tools from an MCP server over transport', async () => {
|
|
33
|
+
const server = new index_js_1.Server({ name: 'test-mcp-srv', version: '1.0.0' }, { capabilities: { tools: {} } });
|
|
34
|
+
server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
|
|
35
|
+
tools: [
|
|
36
|
+
{
|
|
37
|
+
name: 'echo',
|
|
38
|
+
description: 'Echoes back the message',
|
|
39
|
+
inputSchema: {
|
|
40
|
+
type: 'object',
|
|
41
|
+
properties: {
|
|
42
|
+
message: { type: 'string' },
|
|
43
|
+
},
|
|
44
|
+
required: ['message'],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'calculator',
|
|
49
|
+
description: 'Adds two numbers',
|
|
50
|
+
inputSchema: {
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: {
|
|
53
|
+
a: { type: 'number' },
|
|
54
|
+
b: { type: 'number' },
|
|
55
|
+
},
|
|
56
|
+
required: ['a', 'b'],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
}));
|
|
61
|
+
const [clientTransport, serverTransport] = inMemory_js_1.InMemoryTransport.createLinkedPair();
|
|
62
|
+
await server.connect(serverTransport);
|
|
63
|
+
const hands = new index_1.DefaultHandsOrgan({
|
|
64
|
+
secretKey,
|
|
65
|
+
providers: [
|
|
66
|
+
{
|
|
67
|
+
serverName: 'mcp_server',
|
|
68
|
+
transport: clientTransport,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
});
|
|
72
|
+
const tools = await hands.listTools();
|
|
73
|
+
expect(tools.length).toBe(2);
|
|
74
|
+
expect(tools.map((t) => t.name)).toEqual(expect.arrayContaining(['echo', 'calculator']));
|
|
75
|
+
expect(tools.find((t) => t.name === 'echo')?.providerId).toBe('mcp_server');
|
|
76
|
+
await hands.close();
|
|
77
|
+
await server.close();
|
|
78
|
+
});
|
|
79
|
+
test('executes MCP tool call with cryptographic authorization and parameter validation', async () => {
|
|
80
|
+
const server = new index_js_1.Server({ name: 'calc-srv', version: '1.0.0' }, { capabilities: { tools: {} } });
|
|
81
|
+
server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
|
|
82
|
+
tools: [
|
|
83
|
+
{
|
|
84
|
+
name: 'calculator',
|
|
85
|
+
description: 'Adds two numbers',
|
|
86
|
+
inputSchema: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
properties: {
|
|
89
|
+
a: { type: 'number' },
|
|
90
|
+
b: { type: 'number' },
|
|
91
|
+
},
|
|
92
|
+
required: ['a', 'b'],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
}));
|
|
97
|
+
server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
98
|
+
const args = request.params.arguments;
|
|
99
|
+
return {
|
|
100
|
+
content: [
|
|
101
|
+
{
|
|
102
|
+
type: 'text',
|
|
103
|
+
text: JSON.stringify({ sum: args.a + args.b }),
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
const [clientTransport, serverTransport] = inMemory_js_1.InMemoryTransport.createLinkedPair();
|
|
109
|
+
await server.connect(serverTransport);
|
|
110
|
+
const hands = new index_1.DefaultHandsOrgan({
|
|
111
|
+
secretKey,
|
|
112
|
+
providers: [
|
|
113
|
+
{
|
|
114
|
+
serverName: 'mcp_server',
|
|
115
|
+
transport: clientTransport,
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
});
|
|
119
|
+
// Discover tools and register in policy engine
|
|
120
|
+
const tools = await hands.listTools();
|
|
121
|
+
for (const tool of tools) {
|
|
122
|
+
engine.registerToolDefinition(tool);
|
|
123
|
+
}
|
|
124
|
+
// 1. Authorize action
|
|
125
|
+
const action = {
|
|
126
|
+
actionId: 'act-calc-101',
|
|
127
|
+
toolName: 'mcp_server/calculator',
|
|
128
|
+
parameters: { a: 15, b: 27 },
|
|
129
|
+
context: sampleContext,
|
|
130
|
+
};
|
|
131
|
+
const { capability } = await engine.evaluateAction(action);
|
|
132
|
+
expect(capability).toBeDefined();
|
|
133
|
+
// 2. Execute action through hands organ
|
|
134
|
+
const executionResult = await hands.executeAction(action, capability);
|
|
135
|
+
expect(executionResult.success).toBe(true);
|
|
136
|
+
expect(executionResult.lifecycle).toBe('COMPLETED');
|
|
137
|
+
expect(executionResult.result.content[0].text).toBe(JSON.stringify({ sum: 42 }));
|
|
138
|
+
await hands.close();
|
|
139
|
+
await server.close();
|
|
140
|
+
});
|
|
141
|
+
test('propagates MCP tool errors properly as execution failures', async () => {
|
|
142
|
+
const server = new index_js_1.Server({ name: 'err-srv', version: '1.0.0' }, { capabilities: { tools: {} } });
|
|
143
|
+
server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
|
|
144
|
+
tools: [
|
|
145
|
+
{
|
|
146
|
+
name: 'failing_tool',
|
|
147
|
+
description: 'Fails intentionally',
|
|
148
|
+
inputSchema: { type: 'object' },
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
}));
|
|
152
|
+
server.setRequestHandler(types_js_1.CallToolRequestSchema, async () => {
|
|
153
|
+
return {
|
|
154
|
+
isError: true,
|
|
155
|
+
content: [{ type: 'text', text: 'Database connection failed inside MCP server' }],
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
const [clientTransport, serverTransport] = inMemory_js_1.InMemoryTransport.createLinkedPair();
|
|
159
|
+
await server.connect(serverTransport);
|
|
160
|
+
const hands = new index_1.DefaultHandsOrgan({
|
|
161
|
+
secretKey,
|
|
162
|
+
providers: [
|
|
163
|
+
{
|
|
164
|
+
serverName: 'mcp_err',
|
|
165
|
+
transport: clientTransport,
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
});
|
|
169
|
+
const tools = await hands.listTools();
|
|
170
|
+
for (const tool of tools) {
|
|
171
|
+
engine.registerToolDefinition(tool);
|
|
172
|
+
}
|
|
173
|
+
const action = {
|
|
174
|
+
actionId: 'act-err-1',
|
|
175
|
+
toolName: 'mcp_err/failing_tool',
|
|
176
|
+
parameters: {},
|
|
177
|
+
context: sampleContext,
|
|
178
|
+
};
|
|
179
|
+
const { capability } = await engine.evaluateAction(action);
|
|
180
|
+
const result = await hands.executeAction(action, capability);
|
|
181
|
+
expect(result.success).toBe(false);
|
|
182
|
+
expect(result.lifecycle).toBe('FAILED');
|
|
183
|
+
expect(result.error).toContain('Database connection failed inside MCP server');
|
|
184
|
+
await hands.close();
|
|
185
|
+
await server.close();
|
|
186
|
+
});
|
|
187
|
+
test('aborts and handles cancellation of in-flight MCP calls', async () => {
|
|
188
|
+
const server = new index_js_1.Server({ name: 'slow-srv', version: '1.0.0' }, { capabilities: { tools: {} } });
|
|
189
|
+
server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
|
|
190
|
+
tools: [
|
|
191
|
+
{
|
|
192
|
+
name: 'slow_tool',
|
|
193
|
+
description: 'Takes long to run',
|
|
194
|
+
inputSchema: { type: 'object' },
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
}));
|
|
198
|
+
let timer;
|
|
199
|
+
server.setRequestHandler(types_js_1.CallToolRequestSchema, async () => {
|
|
200
|
+
return new Promise((resolve) => {
|
|
201
|
+
timer = setTimeout(() => resolve({ content: [{ type: 'text', text: 'done' }] }), 200);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
const [clientTransport, serverTransport] = inMemory_js_1.InMemoryTransport.createLinkedPair();
|
|
205
|
+
await server.connect(serverTransport);
|
|
206
|
+
const hands = new index_1.DefaultHandsOrgan({
|
|
207
|
+
secretKey,
|
|
208
|
+
providers: [
|
|
209
|
+
{
|
|
210
|
+
serverName: 'mcp_slow',
|
|
211
|
+
transport: clientTransport,
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
});
|
|
215
|
+
const tools = await hands.listTools();
|
|
216
|
+
for (const tool of tools) {
|
|
217
|
+
engine.registerToolDefinition(tool);
|
|
218
|
+
}
|
|
219
|
+
const action = {
|
|
220
|
+
actionId: 'act-abort-1',
|
|
221
|
+
toolName: 'mcp_slow/slow_tool',
|
|
222
|
+
parameters: {},
|
|
223
|
+
context: sampleContext,
|
|
224
|
+
};
|
|
225
|
+
const { capability } = await engine.evaluateAction(action);
|
|
226
|
+
const controller = new AbortController();
|
|
227
|
+
setTimeout(() => controller.abort(), 20);
|
|
228
|
+
const result = await hands.executeAction(action, capability, { signal: controller.signal });
|
|
229
|
+
clearTimeout(timer);
|
|
230
|
+
expect(result.success).toBe(false);
|
|
231
|
+
expect(['CANCELLED', 'TIMED_OUT', 'FAILED']).toContain(result.lifecycle);
|
|
232
|
+
await hands.close();
|
|
233
|
+
await server.close();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
2
|
+
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
3
|
+
import { ToolDefinition } from '@sidurijs/core';
|
|
4
|
+
export interface MCPProviderConfig {
|
|
5
|
+
serverName: string;
|
|
6
|
+
baseUrl?: string;
|
|
7
|
+
command?: string;
|
|
8
|
+
args?: string[];
|
|
9
|
+
env?: Record<string, string>;
|
|
10
|
+
tools?: Array<{
|
|
11
|
+
definition: ToolDefinition;
|
|
12
|
+
execute: (parameters: Record<string, unknown>, signal?: AbortSignal) => Promise<unknown>;
|
|
13
|
+
}>;
|
|
14
|
+
transport?: Transport;
|
|
15
|
+
defaultTimeoutMs?: number;
|
|
16
|
+
}
|
|
17
|
+
export interface MCPToolHandler {
|
|
18
|
+
definition: ToolDefinition;
|
|
19
|
+
execute: (parameters: Record<string, unknown>, signal?: AbortSignal) => Promise<unknown>;
|
|
20
|
+
}
|
|
21
|
+
export declare class MCPClientProvider {
|
|
22
|
+
readonly config: MCPProviderConfig;
|
|
23
|
+
private client?;
|
|
24
|
+
private connected;
|
|
25
|
+
private connectingPromise?;
|
|
26
|
+
constructor(config: MCPProviderConfig);
|
|
27
|
+
get serverName(): string;
|
|
28
|
+
isConnected(): boolean;
|
|
29
|
+
getClient(): Client | undefined;
|
|
30
|
+
connect(): Promise<void>;
|
|
31
|
+
discoverTools(): Promise<MCPToolHandler[]>;
|
|
32
|
+
disconnect(): Promise<void>;
|
|
33
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MCPClientProvider = void 0;
|
|
4
|
+
const index_js_1 = require("@modelcontextprotocol/sdk/client/index.js");
|
|
5
|
+
const stdio_js_1 = require("@modelcontextprotocol/sdk/client/stdio.js");
|
|
6
|
+
const sse_js_1 = require("@modelcontextprotocol/sdk/client/sse.js");
|
|
7
|
+
class MCPClientProvider {
|
|
8
|
+
config;
|
|
9
|
+
client;
|
|
10
|
+
connected = false;
|
|
11
|
+
connectingPromise;
|
|
12
|
+
constructor(config) {
|
|
13
|
+
this.config = config;
|
|
14
|
+
}
|
|
15
|
+
get serverName() {
|
|
16
|
+
return this.config.serverName;
|
|
17
|
+
}
|
|
18
|
+
isConnected() {
|
|
19
|
+
return this.connected && !!this.client;
|
|
20
|
+
}
|
|
21
|
+
getClient() {
|
|
22
|
+
return this.client;
|
|
23
|
+
}
|
|
24
|
+
async connect() {
|
|
25
|
+
if (this.connected) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (this.connectingPromise) {
|
|
29
|
+
return this.connectingPromise;
|
|
30
|
+
}
|
|
31
|
+
this.connectingPromise = (async () => {
|
|
32
|
+
let transport = this.config.transport;
|
|
33
|
+
if (!transport) {
|
|
34
|
+
if (this.config.command) {
|
|
35
|
+
transport = new stdio_js_1.StdioClientTransport({
|
|
36
|
+
command: this.config.command,
|
|
37
|
+
args: this.config.args,
|
|
38
|
+
env: this.config.env,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
else if (this.config.baseUrl) {
|
|
42
|
+
transport = new sse_js_1.SSEClientTransport(new URL(this.config.baseUrl));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (!transport) {
|
|
46
|
+
// In-memory or static tools provider (no remote transport configured)
|
|
47
|
+
this.connected = true;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const client = new index_js_1.Client({
|
|
51
|
+
name: `siduri-x-${this.config.serverName}`,
|
|
52
|
+
version: '1.0.0',
|
|
53
|
+
}, {
|
|
54
|
+
capabilities: {},
|
|
55
|
+
});
|
|
56
|
+
await client.connect(transport);
|
|
57
|
+
this.client = client;
|
|
58
|
+
this.connected = true;
|
|
59
|
+
})();
|
|
60
|
+
try {
|
|
61
|
+
await this.connectingPromise;
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
this.connectingPromise = undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async discoverTools() {
|
|
68
|
+
const handlers = [];
|
|
69
|
+
// 1. If static tools are provided in config, include them
|
|
70
|
+
if (this.config.tools) {
|
|
71
|
+
for (const tool of this.config.tools) {
|
|
72
|
+
handlers.push({
|
|
73
|
+
definition: {
|
|
74
|
+
...tool.definition,
|
|
75
|
+
providerId: this.config.serverName,
|
|
76
|
+
},
|
|
77
|
+
execute: tool.execute,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// 2. If remote client is configured or connectable, discover tools dynamically via MCP
|
|
82
|
+
if (this.config.command || this.config.baseUrl || this.config.transport) {
|
|
83
|
+
if (!this.connected) {
|
|
84
|
+
await this.connect();
|
|
85
|
+
}
|
|
86
|
+
if (this.client) {
|
|
87
|
+
const response = await this.client.listTools();
|
|
88
|
+
for (const remoteTool of response.tools) {
|
|
89
|
+
const definition = {
|
|
90
|
+
name: remoteTool.name,
|
|
91
|
+
description: remoteTool.description || '',
|
|
92
|
+
inputSchema: remoteTool.inputSchema || { type: 'object' },
|
|
93
|
+
providerId: this.config.serverName,
|
|
94
|
+
timeoutMs: this.config.defaultTimeoutMs,
|
|
95
|
+
};
|
|
96
|
+
const execute = async (parameters, signal) => {
|
|
97
|
+
if (!this.client) {
|
|
98
|
+
throw new Error(`MCP Provider "${this.config.serverName}" is not connected`);
|
|
99
|
+
}
|
|
100
|
+
const result = await this.client.callTool({
|
|
101
|
+
name: remoteTool.name,
|
|
102
|
+
arguments: parameters,
|
|
103
|
+
}, undefined, { signal });
|
|
104
|
+
if (result.isError) {
|
|
105
|
+
const errorMessage = Array.isArray(result.content)
|
|
106
|
+
? result.content.map((c) => ('text' in c ? c.text : JSON.stringify(c))).join('\n')
|
|
107
|
+
: 'Tool execution returned error status';
|
|
108
|
+
throw new Error(errorMessage);
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
};
|
|
112
|
+
handlers.push({ definition, execute });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return handlers;
|
|
117
|
+
}
|
|
118
|
+
async disconnect() {
|
|
119
|
+
if (this.client) {
|
|
120
|
+
try {
|
|
121
|
+
await this.client.close();
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// Ignore close errors during cleanup
|
|
125
|
+
}
|
|
126
|
+
this.client = undefined;
|
|
127
|
+
}
|
|
128
|
+
this.connected = false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
exports.MCPClientProvider = MCPClientProvider;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.validateInputSchema = validateInputSchema;
|
|
4
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
5
|
+
function sanitizeAndCheckForbiddenKeys(val, path = '') {
|
|
6
|
+
const errors = [];
|
|
7
|
+
if (val && typeof val === 'object') {
|
|
8
|
+
if (Array.isArray(val)) {
|
|
9
|
+
for (let i = 0; i < val.length; i++) {
|
|
10
|
+
errors.push(...sanitizeAndCheckForbiddenKeys(val[i], `${path}[${i}]`));
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
const rec = val;
|
|
15
|
+
// Check own properties and symbol/special names
|
|
16
|
+
const ownKeys = Object.getOwnPropertyNames(rec);
|
|
17
|
+
for (const key of ownKeys) {
|
|
18
|
+
if (FORBIDDEN_KEYS.has(key)) {
|
|
19
|
+
errors.push(`Forbidden prototype pollution key "${key}" detected at path "${path ? path + '.' + key : key}"`);
|
|
20
|
+
}
|
|
21
|
+
errors.push(...sanitizeAndCheckForbiddenKeys(rec[key], path ? `${path}.${key}` : key));
|
|
22
|
+
}
|
|
23
|
+
// Check constructor or prototype property references
|
|
24
|
+
if (Object.prototype.hasOwnProperty.call(rec, 'constructor')) {
|
|
25
|
+
errors.push(`Forbidden prototype pollution key "constructor" detected at path "${path ? path + '.constructor' : 'constructor'}"`);
|
|
26
|
+
}
|
|
27
|
+
if (Object.prototype.hasOwnProperty.call(rec, '__proto__')) {
|
|
28
|
+
errors.push(`Forbidden prototype pollution key "__proto__" detected at path "${path ? path + '.__proto__' : '__proto__'}"`);
|
|
29
|
+
}
|
|
30
|
+
if (Object.prototype.hasOwnProperty.call(rec, 'prototype')) {
|
|
31
|
+
errors.push(`Forbidden prototype pollution key "prototype" detected at path "${path ? path + '.prototype' : 'prototype'}"`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return errors;
|
|
36
|
+
}
|
|
37
|
+
function validateInputSchema(schema, params, currentPath = '') {
|
|
38
|
+
// Always inspect for prototype pollution keys recursively
|
|
39
|
+
const forbiddenKeyErrors = sanitizeAndCheckForbiddenKeys(params, currentPath);
|
|
40
|
+
if (forbiddenKeyErrors.length > 0) {
|
|
41
|
+
return { valid: false, errors: forbiddenKeyErrors };
|
|
42
|
+
}
|
|
43
|
+
if (!schema || Object.keys(schema).length === 0) {
|
|
44
|
+
return { valid: true, errors: [] };
|
|
45
|
+
}
|
|
46
|
+
const errors = [];
|
|
47
|
+
const parameters = params ?? {};
|
|
48
|
+
// Check type of parameters
|
|
49
|
+
if (typeof parameters !== 'object' || parameters === null || Array.isArray(parameters)) {
|
|
50
|
+
return { valid: false, errors: [`Parameter at "${currentPath || 'root'}" must be a JSON object`] };
|
|
51
|
+
}
|
|
52
|
+
// 1. Required fields
|
|
53
|
+
if (Array.isArray(schema.required)) {
|
|
54
|
+
for (const reqField of schema.required) {
|
|
55
|
+
if (typeof reqField === 'string') {
|
|
56
|
+
if (parameters[reqField] === undefined || parameters[reqField] === null) {
|
|
57
|
+
const fieldPath = currentPath ? `${currentPath}.${reqField}` : reqField;
|
|
58
|
+
errors.push(`Missing required parameter "${fieldPath}"`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// 2. Recursive properties type checking
|
|
64
|
+
const properties = schema.properties;
|
|
65
|
+
if (properties && typeof properties === 'object') {
|
|
66
|
+
for (const [propName, propSchema] of Object.entries(properties)) {
|
|
67
|
+
const fieldPath = currentPath ? `${currentPath}.${propName}` : propName;
|
|
68
|
+
const val = parameters[propName];
|
|
69
|
+
if (val !== undefined && val !== null && propSchema && typeof propSchema === 'object') {
|
|
70
|
+
const expectedType = propSchema.type;
|
|
71
|
+
if (expectedType) {
|
|
72
|
+
if (expectedType === 'string' && typeof val !== 'string') {
|
|
73
|
+
errors.push(`Parameter "${fieldPath}" expected type string, got ${typeof val}`);
|
|
74
|
+
}
|
|
75
|
+
else if (expectedType === 'number' && (typeof val !== 'number' || isNaN(val))) {
|
|
76
|
+
errors.push(`Parameter "${fieldPath}" expected type number, got ${typeof val}`);
|
|
77
|
+
}
|
|
78
|
+
else if (expectedType === 'boolean' && typeof val !== 'boolean') {
|
|
79
|
+
errors.push(`Parameter "${fieldPath}" expected type boolean, got ${typeof val}`);
|
|
80
|
+
}
|
|
81
|
+
else if (expectedType === 'array') {
|
|
82
|
+
if (!Array.isArray(val)) {
|
|
83
|
+
errors.push(`Parameter "${fieldPath}" expected type array, got ${typeof val}`);
|
|
84
|
+
}
|
|
85
|
+
else if (propSchema.items && typeof propSchema.items === 'object') {
|
|
86
|
+
// Recursive array item validation
|
|
87
|
+
for (let i = 0; i < val.length; i++) {
|
|
88
|
+
const itemPath = `${fieldPath}[${i}]`;
|
|
89
|
+
const itemVal = val[i];
|
|
90
|
+
const itemType = propSchema.items.type;
|
|
91
|
+
if (itemType === 'object' || (propSchema.items.properties && typeof itemVal === 'object')) {
|
|
92
|
+
const nestedRes = validateInputSchema(propSchema.items, itemVal, itemPath);
|
|
93
|
+
if (!nestedRes.valid) {
|
|
94
|
+
errors.push(...nestedRes.errors);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
else if (itemType && typeof itemVal !== itemType) {
|
|
98
|
+
errors.push(`Parameter "${itemPath}" expected type ${itemType}, got ${typeof itemVal}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else if (expectedType === 'object') {
|
|
104
|
+
if (typeof val !== 'object' || Array.isArray(val)) {
|
|
105
|
+
errors.push(`Parameter "${fieldPath}" expected type object, got ${typeof val}`);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
// Recursive nested object validation
|
|
109
|
+
const nestedRes = validateInputSchema(propSchema, val, fieldPath);
|
|
110
|
+
if (!nestedRes.valid) {
|
|
111
|
+
errors.push(...nestedRes.errors);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Enum checking
|
|
117
|
+
if (Array.isArray(propSchema.enum)) {
|
|
118
|
+
if (!propSchema.enum.includes(val)) {
|
|
119
|
+
errors.push(`Parameter "${fieldPath}" has invalid value "${val}". Must be one of: [${propSchema.enum.join(', ')}]`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// additionalProperties restriction
|
|
125
|
+
if (schema.additionalProperties === false) {
|
|
126
|
+
for (const key of Object.keys(parameters)) {
|
|
127
|
+
if (!properties[key]) {
|
|
128
|
+
const extraPath = currentPath ? `${currentPath}.${key}` : key;
|
|
129
|
+
errors.push(`Unexpected additional parameter "${extraPath}"`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
valid: errors.length === 0,
|
|
136
|
+
errors,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sidurijs/hands",
|
|
3
|
+
"organType": "hands",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"displayName": "Hands (MCP Tool Execution)",
|
|
6
|
+
"description": "Model Context Protocol tool management and cryptographically authorized action execution",
|
|
7
|
+
"entrypoint": "./dist/index.js",
|
|
8
|
+
"factory": "DefaultHandsOrgan",
|
|
9
|
+
"configKey": "hands",
|
|
10
|
+
"configSchema": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"properties": {
|
|
13
|
+
"defaultTimeoutMs": {
|
|
14
|
+
"type": "number",
|
|
15
|
+
"default": 10000
|
|
16
|
+
},
|
|
17
|
+
"providers": {
|
|
18
|
+
"type": "array",
|
|
19
|
+
"items": {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"required": [
|
|
22
|
+
"serverName"
|
|
23
|
+
],
|
|
24
|
+
"properties": {
|
|
25
|
+
"serverName": {
|
|
26
|
+
"type": "string"
|
|
27
|
+
},
|
|
28
|
+
"baseUrl": {
|
|
29
|
+
"type": "string"
|
|
30
|
+
},
|
|
31
|
+
"command": {
|
|
32
|
+
"type": "string"
|
|
33
|
+
},
|
|
34
|
+
"args": {
|
|
35
|
+
"type": "array",
|
|
36
|
+
"items": {
|
|
37
|
+
"type": "string"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"environment": [
|
|
46
|
+
{
|
|
47
|
+
"name": "ACTION_POLICY_SECRET",
|
|
48
|
+
"required": false,
|
|
49
|
+
"secret": true,
|
|
50
|
+
"description": "HMAC secret key for signing and verifying action execution capabilities (required in production)"
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
"services": [],
|
|
54
|
+
"database": null,
|
|
55
|
+
"healthCheck": "probeHandsHealth"
|
|
56
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sidurijs/hands",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"main": "dist/index.js",
|
|
5
|
+
"types": "dist/index.d.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "tsc",
|
|
8
|
+
"dev": "tsc -w",
|
|
9
|
+
"test": "jest --config jest.config.json"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
13
|
+
"@sidurijs/core": "workspace:*"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/jest": "^30.0.0",
|
|
17
|
+
"@types/node": "^26.5.1",
|
|
18
|
+
"jest": "^30.5.1",
|
|
19
|
+
"ts-jest": "^29.4.12",
|
|
20
|
+
"typescript": "^5.9.3"
|
|
21
|
+
},
|
|
22
|
+
"description": "Hands organ for tool execution, action policy enforcement, and MCP provider integration",
|
|
23
|
+
"license": "Apache-2.0",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "https://github.com/vxnus-studio/siduri-x",
|
|
27
|
+
"directory": "packages/organs/hands"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=22.16.0"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"organ-manifest.json",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE"
|
|
40
|
+
],
|
|
41
|
+
"exports": {
|
|
42
|
+
".": {
|
|
43
|
+
"types": "./dist/index.d.ts",
|
|
44
|
+
"import": "./dist/index.js",
|
|
45
|
+
"default": "./dist/index.js"
|
|
46
|
+
},
|
|
47
|
+
"./organ-manifest.json": "./organ-manifest.json"
|
|
48
|
+
}
|
|
49
|
+
}
|