Skip to content

Commit 7fc5610

Browse files
authored
Merge pull request #2 from API-200/feature/mcp
Feature/mcp
2 parents e2fd73c + d17c670 commit 7fc5610

32 files changed

Lines changed: 4591 additions & 278 deletions

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
"homepage": "https://github.com/API-200/api200-selfhosted#readme",
1818
"description": "",
1919
"dependencies": {
20+
"@modelcontextprotocol/sdk": "^1.9.0",
2021
"jsonwebtoken": "^9.0.2"
21-
}
22+
},
23+
"packageManager": "pnpm@10.8.0+sha256.29bf2c5ceaea7991ee82eec15fe7162e0fad816d0c4a6b35a16c01d39274bf69"
2224
}

packages/backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"@sentry/node": "^9.1.0",
2525
"@sentry/profiling-node": "^9.1.0",
2626
"@supabase/supabase-js": "^2.48.1",
27+
"async-mutex": "^0.5.0",
2728
"axios": "^1.7.9",
2829
"cache-manager": "^6.4.0",
2930
"dotenv": "^16.4.7",

packages/backend/pnpm-lock.yaml

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/backend/src/api-handler.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { getFullUrlWithParams } from './modules/base/mapUrlParams';
2121
import { applyThirdPartyAuth } from './modules/applyThirdPartyAuth';
2222
import { prepareAxiosConfig } from './modules/base/prepareAxiosConfig';
2323
import FEATURES from '@config/features';
24+
import { supabase } from '@utils/supabase';
2425

2526
export const createApiHandlerRouter = () => {
2627
const router = new Router();
@@ -167,5 +168,6 @@ export const createApiHandlerRouter = () => {
167168
);
168169
}
169170
});
171+
170172
return router;
171173
};

packages/backend/src/index.ts

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import bodyParser from 'koa-bodyparser';
77
import { createTestRouter } from './test/test-api-handler';
88
import Sentry from '@sentry/node';
99
import cors from '@koa/cors';
10+
import { createUserRouter } from './user-router';
1011

1112
const app = new Koa({ proxy: true });
1213
app.use(cors());
@@ -18,16 +19,19 @@ const router = new Router();
1819
const isDevelopment = process.env.NODE_ENV === 'development';
1920

2021
const apiHandler = createApiHandlerRouter();
22+
const userRouter = createUserRouter();
2123
const testRouter = createTestRouter();
24+
2225
app.use(apiHandler.routes()).use(apiHandler.allowedMethods());
26+
app.use(userRouter.routes()).use(userRouter.allowedMethods());
2327

2428
if (isDevelopment) {
25-
app.use(testRouter.routes()).use(testRouter.allowedMethods());
29+
app.use(testRouter.routes()).use(testRouter.allowedMethods());
2630
}
2731

2832
// Routes
2933
router.get('/', (ctx) => {
30-
ctx.body = `
34+
ctx.body = `
3135
<html>
3236
<body>
3337
<h1>Welcome to api200!</h1>
@@ -38,24 +42,24 @@ router.get('/', (ctx) => {
3842
});
3943

4044
router.get('/healthcheck', (ctx) => {
41-
ctx.body = {
42-
status: 'OK',
43-
timestamp: new Date().toISOString(),
44-
};
45+
ctx.body = {
46+
status: 'OK',
47+
timestamp: new Date().toISOString(),
48+
};
4549
});
4650

4751
// Apply routes
4852
app.use(router.routes()).use(router.allowedMethods());
4953

5054
// Start the server
5155
const server = app.listen(config.PORT, '0.0.0.0', () => {
52-
console.log(`✅ Server running at http://localhost:${config.PORT}`);
56+
console.log(`✅ Server running at http://localhost:${config.PORT}`);
5357
});
5458

55-
// Graceful shutdown
56-
process.on('SIGTERM', () => {
57-
console.log('🛑 SIGTERM received. Shutting down gracefully');
58-
server.close(() => {
59-
console.log('💤 Process terminated');
60-
});
61-
});
59+
// // Graceful shutdown
60+
// process.on('SIGTERM', () => {
61+
// console.log('🛑 SIGTERM received. Shutting down gracefully');
62+
// server.close(() => {
63+
// console.log('💤 Process terminated');
64+
// });
65+
// });
Lines changed: 157 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,168 @@
1-
import { Tables } from '../../utils/database.types';
1+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3+
import { z } from "zod";
24

3-
export const getFullUrlWithParams = (
4-
endpoint: Tables<'endpoints'>,
5-
endpointName: string,
6-
): string => {
7-
// Ensure endpointName starts with a forward slash
8-
const normalizedEndpointName = endpointName.startsWith('/') ? endpointName : `/${endpointName}`;
5+
// Helper function to construct the full URL with properly replaced parameters
6+
const getFullUrlWithParams = (service: any, endpoint: any, params: any): string => {
7+
// Base API URL structure
8+
const baseApiUrl = "https://eu.api200.co/api";
99

10-
if (endpoint.regex_path === `^${endpoint.name}$`) {
11-
return endpoint.full_url;
12-
}
10+
// Get service name
11+
const serviceName = service.name;
1312

14-
const regex = new RegExp(endpoint.regex_path!);
15-
const matches = normalizedEndpointName.match(regex);
13+
// Start with endpoint name
14+
let endpointPath = endpoint.name;
1615

17-
if (!matches) {
18-
//TODO return instead of throwing so it wont be catched in global error handler
19-
throw new Error(`API200 Error: Path parameters don't match provided`);
16+
// Replace path parameters in the URL
17+
if (endpoint.schema && endpoint.schema.parameters) {
18+
endpoint.schema.parameters.forEach((param: any) => {
19+
if (param.in === 'path' && params[param.name]) {
20+
// Replace {paramName} with actual value
21+
endpointPath = endpointPath.replace(`{${param.name}}`, params[param.name]);
22+
}
23+
});
2024
}
2125

22-
// Get the parameter names from the full_url
23-
const paramNames = endpoint.full_url.match(/\{([^}]+)\}/g) || [];
26+
// Construct full URL
27+
const fullUrl = `${baseApiUrl}/${serviceName}${endpointPath}`;
28+
29+
// Add query parameters if any
30+
const queryParams: string[] = [];
31+
if (endpoint.schema && endpoint.schema.parameters) {
32+
endpoint.schema.parameters.forEach((param: any) => {
33+
if (param.in === 'query' && params[param.name] !== undefined) {
34+
queryParams.push(`${param.name}=${encodeURIComponent(params[param.name])}`);
35+
}
36+
});
37+
}
2438

25-
// Start with the full URL
26-
let finalUrl = endpoint.full_url;
39+
// Append query string if query parameters exist
40+
return queryParams.length > 0 ? `${fullUrl}?${queryParams.join('&')}` : fullUrl;
41+
};
2742

28-
// Replace each parameter with its corresponding value
29-
paramNames.forEach((param, index) => {
30-
// matches[0] is the full match, so we start from index 1 for capture groups
31-
const value = matches[index + 1];
32-
if (value) {
33-
finalUrl = finalUrl.replace(param, value);
34-
}
43+
const main = async () => {
44+
const server = new McpServer({
45+
name: "API200 Client",
46+
version: "1.0.0"
3547
});
3648

37-
return finalUrl;
49+
try {
50+
const userKey = "022fad02fed409a185c42c4416cea7c0";
51+
52+
const response = await fetch('http://localhost:8080/user/mcp-services', {
53+
headers: {
54+
"x-api-key": userKey
55+
}
56+
});
57+
const data = await response.json();
58+
59+
data.forEach((service: any) => {
60+
console.log(`Processing service: ${service.name}`);
61+
62+
service.endpoints.forEach((endpoint: any) => {
63+
console.log(`Processing endpoint: ${endpoint.name} (${endpoint.method})`);
64+
65+
// Create Zod schema dynamically based on endpoint parameters
66+
const paramSchema: Record<string, any> = {};
67+
68+
if (endpoint.schema && endpoint.schema.parameters) {
69+
endpoint.schema.parameters.forEach((param: any) => {
70+
// Determine the Zod type based on the parameter type
71+
let zodType;
72+
switch (param.type) {
73+
case 'integer':
74+
zodType = z.number().int();
75+
break;
76+
case 'number':
77+
zodType = z.number();
78+
break;
79+
case 'string':
80+
zodType = z.string();
81+
break;
82+
case 'boolean':
83+
zodType = z.boolean();
84+
break;
85+
default:
86+
zodType = z.string();
87+
}
88+
89+
// Make it optional if not required
90+
if (!param.required) {
91+
zodType = zodType.optional();
92+
}
93+
94+
// Add description if available
95+
if (param.description) {
96+
zodType = zodType.describe(param.description);
97+
}
98+
99+
paramSchema[param.name] = zodType;
100+
});
101+
}
102+
103+
// Create a formatted toolName from the endpoint name
104+
// Remove leading slash and replace remaining slashes with underscores
105+
let toolName = endpoint.name.replace(/^\//, '').replace(/\//g, '_');
106+
// Replace curly braces notation with "by" prefix
107+
toolName = toolName.replace(/{([^}]+)}/g, 'by_$1');
108+
109+
// Register the tool with the server
110+
server.tool(
111+
toolName, // Tool name
112+
endpoint.description || `${endpoint.method} ${endpoint.name}`, // Description
113+
paramSchema, // Zod schema
114+
async (params) => {
115+
try {
116+
// Get the properly constructed URL, passing service object to the helper function
117+
const url = getFullUrlWithParams(service, endpoint, params);
118+
119+
console.log(`Making ${endpoint.method} request to: ${url}`);
120+
121+
// Make the actual API call
122+
const apiResponse = await fetch(url, {
123+
method: endpoint.method,
124+
headers: {
125+
"Accept": "application/json",
126+
"Content-Type": "application/json",
127+
"x-api-key": userKey
128+
}
129+
});
130+
131+
const data = await apiResponse.json();
132+
133+
// Return formatted response
134+
return {
135+
content: [
136+
{
137+
type: "text",
138+
text: JSON.stringify(data, null, 2)
139+
}
140+
]
141+
};
142+
} catch (error) {
143+
console.error(`Error calling ${endpoint.name}:`, error);
144+
return {
145+
content: [
146+
{
147+
type: "text",
148+
text: `Error: ${error instanceof Error ? error.message : String(error)}`
149+
}
150+
]
151+
};
152+
}
153+
}
154+
);
155+
156+
console.log(`Registered tool: ${toolName}`);
157+
});
158+
});
159+
} catch (error) {
160+
console.error("Error setting up MCP tools:", error);
161+
}
162+
163+
// Start receiving messages on stdin and sending messages on stdout
164+
const transport = new StdioServerTransport();
165+
await server.connect(transport);
38166
};
167+
168+
main();
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { validateApiKey } from '@modules/base/apiKeyValidation';
2+
import { supabase } from '@utils/supabase';
3+
import Router from 'koa-router';
4+
5+
export const createUserRouter = () => {
6+
const router = new Router({ prefix: '/user' });
7+
8+
router.get('/mcp-services', async (ctx) => {
9+
const keyData = await validateApiKey(ctx);
10+
if (!keyData) {
11+
ctx.status = 401;
12+
ctx.body = { error: 'Unauthorized' };
13+
return;
14+
}
15+
16+
const mcpServices = await supabase
17+
.from('services')
18+
.select('*, endpoints(*)')
19+
.eq('is_mcp_enabled', true)
20+
.eq('user_id', keyData.user_id);
21+
22+
if (mcpServices.error) {
23+
ctx.status = 500;
24+
ctx.body = { error: 'Failed to fetch MCP services' };
25+
return;
26+
}
27+
28+
ctx.status = 200;
29+
ctx.body = mcpServices.data;
30+
});
31+
32+
return router;
33+
}

0 commit comments

Comments
 (0)