tinker-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/tools.test.js +139 -0
- package/dist/api.js +20 -0
- package/dist/index.js +48 -0
- package/dist/tools/index.js +21 -0
- package/dist/tools/loader.js +48 -0
- package/dist/verify.js +61 -0
- package/package.json +33 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const index_1 = require("../tools/index");
|
|
4
|
+
const api_1 = require("../api");
|
|
5
|
+
// Mock the API client
|
|
6
|
+
jest.mock('../api', () => ({
|
|
7
|
+
api: {
|
|
8
|
+
get: jest.fn(),
|
|
9
|
+
post: jest.fn()
|
|
10
|
+
}
|
|
11
|
+
}));
|
|
12
|
+
describe('MCP Tools (Dynamic Loader)', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
jest.clearAllMocks();
|
|
15
|
+
});
|
|
16
|
+
describe('Tool Loading', () => {
|
|
17
|
+
it('loads tools from Rails API on initialization', async () => {
|
|
18
|
+
const mockTools = [
|
|
19
|
+
{
|
|
20
|
+
name: 'list_tickets',
|
|
21
|
+
description: 'List tickets',
|
|
22
|
+
parameters: {
|
|
23
|
+
type: 'object',
|
|
24
|
+
properties: {
|
|
25
|
+
status: { type: 'string' }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'transition_ticket',
|
|
31
|
+
description: 'Transition ticket',
|
|
32
|
+
parameters: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
ticket_id: { type: 'integer' },
|
|
36
|
+
event: { type: 'string' }
|
|
37
|
+
},
|
|
38
|
+
required: ['ticket_id', 'event']
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
];
|
|
42
|
+
api_1.api.get.mockResolvedValue({
|
|
43
|
+
data: { tools: mockTools }
|
|
44
|
+
});
|
|
45
|
+
await (0, index_1.initializeTools)();
|
|
46
|
+
expect(api_1.api.get).toHaveBeenCalledWith('/mcp/tools');
|
|
47
|
+
});
|
|
48
|
+
it('handles tool loading errors gracefully', async () => {
|
|
49
|
+
const error = new Error('Rails API unavailable');
|
|
50
|
+
api_1.api.get.mockRejectedValue(error);
|
|
51
|
+
await expect((0, index_1.initializeTools)()).rejects.toThrow('Rails API unavailable');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe('Tool Execution', () => {
|
|
55
|
+
beforeEach(async () => {
|
|
56
|
+
// Initialize tools before each execution test
|
|
57
|
+
const mockTools = [
|
|
58
|
+
{
|
|
59
|
+
name: 'list_tickets',
|
|
60
|
+
description: 'List tickets',
|
|
61
|
+
parameters: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: {
|
|
64
|
+
status: { type: 'string' }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'transition_ticket',
|
|
70
|
+
description: 'Transition ticket',
|
|
71
|
+
parameters: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
ticket_id: { type: 'integer' },
|
|
75
|
+
event: { type: 'string' }
|
|
76
|
+
},
|
|
77
|
+
required: ['ticket_id', 'event']
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
];
|
|
81
|
+
api_1.api.get.mockResolvedValue({
|
|
82
|
+
data: { tools: mockTools }
|
|
83
|
+
});
|
|
84
|
+
await (0, index_1.initializeTools)();
|
|
85
|
+
});
|
|
86
|
+
it('executes list_tickets tool', async () => {
|
|
87
|
+
const mockResponse = {
|
|
88
|
+
data: [{ id: 1, title: 'Ticket 1' }],
|
|
89
|
+
meta: { total_count: 150, offset: 0, limit: 20, has_more: true }
|
|
90
|
+
};
|
|
91
|
+
api_1.api.post.mockResolvedValue({ data: { result: mockResponse } });
|
|
92
|
+
const result = await (0, index_1.executeToolWrapper)('list_tickets', { status: 'todo' });
|
|
93
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
94
|
+
expect(api_1.api.post).toHaveBeenCalledWith('/mcp/execute', {
|
|
95
|
+
tool: 'list_tickets',
|
|
96
|
+
params: { status: 'todo' }
|
|
97
|
+
});
|
|
98
|
+
expect(parsed).toEqual(mockResponse);
|
|
99
|
+
});
|
|
100
|
+
it('executes transition_ticket tool', async () => {
|
|
101
|
+
const mockTicket = { id: 1, status: 'in_progress' };
|
|
102
|
+
api_1.api.post.mockResolvedValue({ data: { result: mockTicket } });
|
|
103
|
+
const result = await (0, index_1.executeToolWrapper)('transition_ticket', { ticket_id: 1, event: 'start_work' });
|
|
104
|
+
expect(api_1.api.post).toHaveBeenCalledWith('/mcp/execute', {
|
|
105
|
+
tool: 'transition_ticket',
|
|
106
|
+
params: { ticket_id: 1, event: 'start_work' }
|
|
107
|
+
});
|
|
108
|
+
expect(JSON.parse(result.content[0].text)).toEqual(mockTicket);
|
|
109
|
+
});
|
|
110
|
+
it('handles tool execution errors', async () => {
|
|
111
|
+
const error = new Error('Network Error');
|
|
112
|
+
api_1.api.post.mockRejectedValue(error);
|
|
113
|
+
const result = await (0, index_1.executeToolWrapper)('list_tickets', {});
|
|
114
|
+
expect(result.isError).toBe(true);
|
|
115
|
+
expect(result.content[0].text).toContain('Network Error');
|
|
116
|
+
});
|
|
117
|
+
it('handles API response errors', async () => {
|
|
118
|
+
const error = {
|
|
119
|
+
response: {
|
|
120
|
+
status: 500,
|
|
121
|
+
data: { error: 'Internal Server Error' }
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
api_1.api.post.mockRejectedValue(error);
|
|
125
|
+
const result = await (0, index_1.executeToolWrapper)('list_tickets', {});
|
|
126
|
+
expect(result.isError).toBe(true);
|
|
127
|
+
expect(result.content[0].text).toContain('500');
|
|
128
|
+
});
|
|
129
|
+
it('handles network errors (no response)', async () => {
|
|
130
|
+
const error = {
|
|
131
|
+
request: {}
|
|
132
|
+
};
|
|
133
|
+
api_1.api.post.mockRejectedValue(error);
|
|
134
|
+
const result = await (0, index_1.executeToolWrapper)('list_tickets', {});
|
|
135
|
+
expect(result.isError).toBe(true);
|
|
136
|
+
expect(result.content[0].text).toContain('Network Error');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
});
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.api = void 0;
|
|
7
|
+
const axios_1 = __importDefault(require("axios"));
|
|
8
|
+
const API_BASE_URL = process.env.RAILS_API_URL || 'http://localhost:3000/api/v1';
|
|
9
|
+
const API_KEY = process.env.RAILS_API_KEY;
|
|
10
|
+
if (!API_KEY) {
|
|
11
|
+
console.error("Warning: RAILS_API_KEY not found in environment variables.");
|
|
12
|
+
}
|
|
13
|
+
exports.api = axios_1.default.create({
|
|
14
|
+
baseURL: API_BASE_URL,
|
|
15
|
+
headers: {
|
|
16
|
+
'Content-Type': 'application/json',
|
|
17
|
+
'X-API-Key': API_KEY,
|
|
18
|
+
'Accept': 'application/json'
|
|
19
|
+
}
|
|
20
|
+
});
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
|
|
4
|
+
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
5
|
+
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
|
|
6
|
+
const index_js_2 = require("./tools/index.js");
|
|
7
|
+
const server = new index_js_1.Server({
|
|
8
|
+
name: "tinker-bridge",
|
|
9
|
+
version: "1.0.0",
|
|
10
|
+
}, {
|
|
11
|
+
capabilities: {
|
|
12
|
+
tools: {},
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
// List tools - dynamically loaded from Rails
|
|
16
|
+
server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
|
|
17
|
+
return {
|
|
18
|
+
tools: index_js_2.tools.map(t => ({
|
|
19
|
+
name: t.name,
|
|
20
|
+
description: t.description,
|
|
21
|
+
inputSchema: t.inputSchema
|
|
22
|
+
})),
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
// Execute tool - route all calls through Rails
|
|
26
|
+
server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
27
|
+
const toolName = request.params.name;
|
|
28
|
+
// Verify tool exists
|
|
29
|
+
const toolExists = index_js_2.tools.some((t) => t.name === toolName);
|
|
30
|
+
if (!toolExists) {
|
|
31
|
+
throw new Error(`Tool not found: ${toolName}`);
|
|
32
|
+
}
|
|
33
|
+
// Execute via Rails
|
|
34
|
+
return await (0, index_js_2.executeToolWrapper)(toolName, request.params.arguments);
|
|
35
|
+
});
|
|
36
|
+
async function main() {
|
|
37
|
+
// Load tools from Rails before starting server
|
|
38
|
+
console.error("Loading tools from Rails API...");
|
|
39
|
+
await (0, index_js_2.initializeTools)();
|
|
40
|
+
console.error(`Loaded ${index_js_2.tools.length} tools`);
|
|
41
|
+
const transport = new stdio_js_1.StdioServerTransport();
|
|
42
|
+
await server.connect(transport);
|
|
43
|
+
console.error("Tinker MCP Bridge running on stdio");
|
|
44
|
+
}
|
|
45
|
+
main().catch((error) => {
|
|
46
|
+
console.error("Server error:", error);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.tools = void 0;
|
|
4
|
+
exports.initializeTools = initializeTools;
|
|
5
|
+
exports.executeToolWrapper = executeToolWrapper;
|
|
6
|
+
const loader_js_1 = require("./loader.js");
|
|
7
|
+
// Export tools dynamically loaded from Rails
|
|
8
|
+
// This ensures Rails is the single source of truth for tool definitions
|
|
9
|
+
exports.tools = [];
|
|
10
|
+
// Export load function to be called during server initialization
|
|
11
|
+
async function initializeTools() {
|
|
12
|
+
const loadedTools = await (0, loader_js_1.loadTools)();
|
|
13
|
+
// Clear existing tools and load from Rails
|
|
14
|
+
exports.tools.length = 0;
|
|
15
|
+
exports.tools.push(...loadedTools);
|
|
16
|
+
console.log(`Loaded ${exports.tools.length} tools from Rails API`);
|
|
17
|
+
}
|
|
18
|
+
// Export a generic tool executor that routes all tool calls through Rails
|
|
19
|
+
async function executeToolWrapper(toolName, args) {
|
|
20
|
+
return (0, loader_js_1.executeTool)(toolName, args);
|
|
21
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.loadTools = loadTools;
|
|
4
|
+
exports.executeTool = executeTool;
|
|
5
|
+
const api_js_1 = require("../api.js");
|
|
6
|
+
// Fetch tool definitions dynamically from Rails
|
|
7
|
+
async function loadTools() {
|
|
8
|
+
try {
|
|
9
|
+
const response = await api_js_1.api.get('/mcp/tools');
|
|
10
|
+
// Transform Rails format to MCP Tool format
|
|
11
|
+
const tools = response.data.tools.map((railsTool) => ({
|
|
12
|
+
name: railsTool.name,
|
|
13
|
+
description: railsTool.description,
|
|
14
|
+
inputSchema: railsTool.parameters
|
|
15
|
+
}));
|
|
16
|
+
return tools;
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
console.error('Failed to load tools from Rails:', error);
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// Generic execute function that calls Rails /mcp/execute
|
|
24
|
+
async function executeTool(toolName, args) {
|
|
25
|
+
try {
|
|
26
|
+
const response = await api_js_1.api.post('/mcp/execute', {
|
|
27
|
+
tool: toolName,
|
|
28
|
+
params: args
|
|
29
|
+
});
|
|
30
|
+
return {
|
|
31
|
+
content: [{ type: "text", text: JSON.stringify(response.data.result, null, 2) }]
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
// Format error consistently
|
|
36
|
+
let errorMessage = `Error: ${error.message}`;
|
|
37
|
+
if (error.response) {
|
|
38
|
+
errorMessage = `API Error (${error.response.status}): ${JSON.stringify(error.response.data, null, 2)}`;
|
|
39
|
+
}
|
|
40
|
+
else if (error.request) {
|
|
41
|
+
errorMessage = `Network Error: No response received from API. Is Rails running?`;
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
content: [{ type: "text", text: errorMessage }],
|
|
45
|
+
isError: true
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const index_js_1 = require("./tools/index.js");
|
|
4
|
+
async function verify() {
|
|
5
|
+
console.log("=== Verification Started ===");
|
|
6
|
+
// Initialize tools from Rails
|
|
7
|
+
await (0, index_js_1.initializeTools)();
|
|
8
|
+
// Helper to execute tools
|
|
9
|
+
const execute = async (toolName, args) => {
|
|
10
|
+
return await (0, index_js_1.executeToolWrapper)(toolName, args);
|
|
11
|
+
};
|
|
12
|
+
// 1. List Tickets (renamed from list_tasks)
|
|
13
|
+
console.log("\n[1] Listing Tickets...");
|
|
14
|
+
const listResult = await execute("list_tickets", { status: "todo" });
|
|
15
|
+
if (listResult.isError) {
|
|
16
|
+
console.error("List Tickets Failed:", listResult.content[0].text);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
let tickets;
|
|
20
|
+
try {
|
|
21
|
+
const parsed = JSON.parse(listResult.content[0].text);
|
|
22
|
+
tickets = parsed.data || parsed; // Handle JSON:API or raw array
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
console.error("Failed to parse list response:", listResult.content[0].text.substring(0, 200));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
console.log(`Found ${tickets?.length || 0} tickets.`);
|
|
29
|
+
if (!tickets || tickets.length === 0) {
|
|
30
|
+
console.error("No tickets found to start work on. Seed data missing?");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const ticketId = tickets[0].id;
|
|
34
|
+
console.log(`Target Ticket ID: ${ticketId}`);
|
|
35
|
+
// 2. Start Work (Transition ticket)
|
|
36
|
+
console.log("\n[2] Starting Work...");
|
|
37
|
+
const startResult = await execute("transition_ticket", { ticket_id: parseInt(ticketId), event: "start_work" });
|
|
38
|
+
if (startResult.isError) {
|
|
39
|
+
console.error("Start Work Failed:", startResult.content[0].text);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
console.log("Start Work Successful:", startResult.content[0].text.substring(0, 100) + "...");
|
|
43
|
+
// 3. Submit Review (Transition ticket)
|
|
44
|
+
console.log("\n[3] Submitting for Review...");
|
|
45
|
+
const submitResult = await execute("transition_ticket", { ticket_id: parseInt(ticketId), event: "submit_review" });
|
|
46
|
+
if (submitResult.isError) {
|
|
47
|
+
console.error("Submit Review Failed:", submitResult.content[0].text);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
console.log("Submit Review Result:", submitResult.content[0].text.substring(0, 100) + "...");
|
|
51
|
+
}
|
|
52
|
+
// 4. Search Memory
|
|
53
|
+
console.log("\n[4] Searching Memory...");
|
|
54
|
+
const searchResult = await execute("search_memory", { query: "Verified" });
|
|
55
|
+
console.log("Search Result:", searchResult.content[0].text);
|
|
56
|
+
console.log("\n=== Verification Completed ===");
|
|
57
|
+
}
|
|
58
|
+
verify().catch(error => {
|
|
59
|
+
console.error("Verification Script Error:", error);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tinker-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"main": "dist/index.js",
|
|
5
|
+
"bin": {
|
|
6
|
+
"tinker-mcp": "./dist/index.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"test": "jest"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [],
|
|
16
|
+
"author": "",
|
|
17
|
+
"license": "ISC",
|
|
18
|
+
"description": "",
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@modelcontextprotocol/sdk": "^1.25.1",
|
|
21
|
+
"axios": "^1.13.2",
|
|
22
|
+
"dotenv": "^17.2.3",
|
|
23
|
+
"zod": "^4.2.1"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/jest": "^30.0.0",
|
|
27
|
+
"@types/node": "^25.0.3",
|
|
28
|
+
"jest": "^30.2.0",
|
|
29
|
+
"ts-jest": "^29.4.6",
|
|
30
|
+
"ts-node": "^10.9.2",
|
|
31
|
+
"typescript": "^5.9.3"
|
|
32
|
+
}
|
|
33
|
+
}
|