sopo-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.
Files changed (78) hide show
  1. package/.dockerignore +10 -0
  2. package/.env +7 -0
  3. package/.ghaymah.json +20 -0
  4. package/Dockerfile +37 -0
  5. package/GUIDE.md +311 -0
  6. package/README.md +143 -0
  7. package/dist/index.d.ts +3 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +94 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/prompts/sopo.prompts.d.ts +8 -0
  12. package/dist/prompts/sopo.prompts.d.ts.map +1 -0
  13. package/dist/prompts/sopo.prompts.js +102 -0
  14. package/dist/prompts/sopo.prompts.js.map +1 -0
  15. package/dist/resources/platform.resources.d.ts +9 -0
  16. package/dist/resources/platform.resources.d.ts.map +1 -0
  17. package/dist/resources/platform.resources.js +143 -0
  18. package/dist/resources/platform.resources.js.map +1 -0
  19. package/dist/sopo-client.d.ts +51 -0
  20. package/dist/sopo-client.d.ts.map +1 -0
  21. package/dist/sopo-client.js +145 -0
  22. package/dist/sopo-client.js.map +1 -0
  23. package/dist/tools/aggregate-request.tools.d.ts +8 -0
  24. package/dist/tools/aggregate-request.tools.d.ts.map +1 -0
  25. package/dist/tools/aggregate-request.tools.js +57 -0
  26. package/dist/tools/aggregate-request.tools.js.map +1 -0
  27. package/dist/tools/auth.tools.d.ts +12 -0
  28. package/dist/tools/auth.tools.d.ts.map +1 -0
  29. package/dist/tools/auth.tools.js +152 -0
  30. package/dist/tools/auth.tools.js.map +1 -0
  31. package/dist/tools/collection.tools.d.ts +8 -0
  32. package/dist/tools/collection.tools.d.ts.map +1 -0
  33. package/dist/tools/collection.tools.js +54 -0
  34. package/dist/tools/collection.tools.js.map +1 -0
  35. package/dist/tools/gateway-plugin.tools.d.ts +8 -0
  36. package/dist/tools/gateway-plugin.tools.d.ts.map +1 -0
  37. package/dist/tools/gateway-plugin.tools.js +64 -0
  38. package/dist/tools/gateway-plugin.tools.js.map +1 -0
  39. package/dist/tools/gateway-route.tools.d.ts +8 -0
  40. package/dist/tools/gateway-route.tools.d.ts.map +1 -0
  41. package/dist/tools/gateway-route.tools.js +68 -0
  42. package/dist/tools/gateway-route.tools.js.map +1 -0
  43. package/dist/tools/gateway.tools.d.ts +8 -0
  44. package/dist/tools/gateway.tools.d.ts.map +1 -0
  45. package/dist/tools/gateway.tools.js +56 -0
  46. package/dist/tools/gateway.tools.js.map +1 -0
  47. package/dist/tools/observability.tools.d.ts +9 -0
  48. package/dist/tools/observability.tools.d.ts.map +1 -0
  49. package/dist/tools/observability.tools.js +54 -0
  50. package/dist/tools/observability.tools.js.map +1 -0
  51. package/dist/tools/service-target.tools.d.ts +8 -0
  52. package/dist/tools/service-target.tools.d.ts.map +1 -0
  53. package/dist/tools/service-target.tools.js +52 -0
  54. package/dist/tools/service-target.tools.js.map +1 -0
  55. package/dist/tools/service.tools.d.ts +8 -0
  56. package/dist/tools/service.tools.d.ts.map +1 -0
  57. package/dist/tools/service.tools.js +67 -0
  58. package/dist/tools/service.tools.js.map +1 -0
  59. package/dist/tools/user-profile.tools.d.ts +8 -0
  60. package/dist/tools/user-profile.tools.d.ts.map +1 -0
  61. package/dist/tools/user-profile.tools.js +46 -0
  62. package/dist/tools/user-profile.tools.js.map +1 -0
  63. package/package.json +33 -0
  64. package/src/index.ts +115 -0
  65. package/src/prompts/sopo.prompts.ts +128 -0
  66. package/src/resources/platform.resources.ts +163 -0
  67. package/src/sopo-client.ts +180 -0
  68. package/src/tools/aggregate-request.tools.ts +83 -0
  69. package/src/tools/auth.tools.ts +187 -0
  70. package/src/tools/collection.tools.ts +80 -0
  71. package/src/tools/gateway-plugin.tools.ts +90 -0
  72. package/src/tools/gateway-route.tools.ts +94 -0
  73. package/src/tools/gateway.tools.ts +82 -0
  74. package/src/tools/observability.tools.ts +87 -0
  75. package/src/tools/service-target.tools.ts +78 -0
  76. package/src/tools/service.tools.ts +93 -0
  77. package/src/tools/user-profile.tools.ts +72 -0
  78. package/tsconfig.json +19 -0
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Sopo MCP Server — Resources
3
+ *
4
+ * MCP Resources provide read-only context to AI models about the Sopo platform.
5
+ * These are data sources the AI can read at any time to understand the system.
6
+ */
7
+
8
+ import { sopoGet } from '../sopo-client.js';
9
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
+
11
+ export function registerResources(server: McpServer): void {
12
+
13
+ // ── Platform Overview Resource ────────────────────────────────
14
+ server.resource(
15
+ 'sopo-platform-overview',
16
+ 'sopo://platform/overview',
17
+ {
18
+ description: 'Overview of the Sopo API Gateway platform, its architecture, domain model, and available API endpoints.',
19
+ mimeType: 'text/markdown',
20
+ },
21
+ async () => ({
22
+ contents: [{
23
+ uri: 'sopo://platform/overview',
24
+ mimeType: 'text/markdown',
25
+ text: `# Sopo API Gateway Platform
26
+
27
+ ## What is Sopo?
28
+ Sopo is a full-featured **API Gateway management platform**. It allows users to create and manage API gateways that route, load-balance, and apply middleware to incoming HTTP requests.
29
+
30
+ ## Architecture
31
+ - **Backend** (Node.js/TypeScript + Express): The BFF (Backend-for-Frontend) layer that exposes REST APIs and proxies GraphQL to Hasura.
32
+ - **Hasura**: GraphQL engine connected to PostgreSQL (metadata) and ClickHouse (logs/metrics).
33
+ - **Go Gateway Server**: The actual reverse proxy that processes live traffic based on the configuration defined via the Backend.
34
+ - **Frontend** (Next.js): Dashboard UI for managing gateways.
35
+
36
+ ## Domain Model (Hierarchy)
37
+ \`\`\`
38
+ User
39
+ └── Gateway (API Gateway instance)
40
+ ├── Service (upstream backend)
41
+ │ └── ServiceTarget (URL + weight for load balancing)
42
+ ├── GatewayRoute (path → service mapping)
43
+ │ └── AggregateRequest (parallel sub-requests)
44
+ ├── GatewayPlugin (middleware: auth, rate-limit, cors, etc.)
45
+ └── Collection (organizational group, Pro mode only)
46
+ \`\`\`
47
+
48
+ ## Key Concepts
49
+ - **Gateway**: A logical API gateway that users create. Has modes: "single" (simple) and "pro" (collections).
50
+ - **Service**: Represents an upstream backend with health checking and load balancing configuration.
51
+ - **ServiceTarget**: An actual URL endpoint for a service. Multiple targets enable load balancing.
52
+ - **GatewayRoute**: Maps an incoming path/method to a downstream service.
53
+ - **AggregateRequest**: Defines sub-requests for parallel fan-out on a single route.
54
+ - **GatewayPlugin**: Middleware (e.g., apikey, rate_limit, cors) attached to a gateway or route. Mutually exclusive: gateway_id OR route_id.
55
+ - **Collection**: Organizational grouping of routes/services (Pro mode only).
56
+ - **UserProfile**: Contains the user's slug (URL namespace).
57
+
58
+ ## Available API Endpoints
59
+ All protected routes require a valid JWT Bearer token.
60
+
61
+ ### System
62
+ - \`GET /health\` — Health check
63
+
64
+ ### Gateway CRUD (/api/v1/gateways)
65
+ - POST / — Create gateway
66
+ - GET / — List gateways
67
+ - PATCH /:id — Update gateway
68
+ - DELETE /:id — Delete gateway
69
+
70
+ ### Service CRUD (/api/v1/services)
71
+ - POST / — Create service (requires gateway_id)
72
+ - GET / — List services
73
+ - PATCH /:id — Update service
74
+ - DELETE /:id — Delete service
75
+
76
+ ### Service Targets CRUD (/api/v1/service-targets)
77
+ - POST / — Create target (requires service_id)
78
+ - GET / — List targets
79
+ - PATCH /:id — Update target
80
+ - DELETE /:id — Delete target
81
+
82
+ ### Gateway Routes CRUD (/api/v1/gateway-routes)
83
+ - POST / — Create route (requires gateway_id + service_id)
84
+ - GET / — List routes
85
+ - PATCH /:id — Update route
86
+ - DELETE /:id — Delete route
87
+
88
+ ### Aggregate Requests CRUD (/api/v1/aggregate-requests)
89
+ - POST / — Create aggregate request (requires route_id + service_id)
90
+ - GET / — List aggregate requests
91
+ - PATCH /:id — Update aggregate request
92
+ - DELETE /:id — Delete aggregate request
93
+
94
+ ### Gateway Plugins CRUD (/api/v1/gateway-plugins)
95
+ - POST / — Create plugin (requires gateway_id XOR route_id)
96
+ - GET / — List plugins
97
+ - PATCH /:id — Update plugin
98
+ - DELETE /:id — Delete plugin
99
+
100
+ ### Collections CRUD (/api/v1/collections)
101
+ - POST / — Create collection (requires gateway_id)
102
+ - GET / — List collections
103
+ - PATCH /:id — Update collection
104
+ - DELETE /:id — Delete collection
105
+
106
+ ### User Profiles (/api/v1/user-profiles)
107
+ - POST / — Create profile (set slug)
108
+ - GET / — Get profile
109
+ - PATCH / — Update slug
110
+ - DELETE / — Delete profile
111
+
112
+ ### Logs & Metrics (/api/v1/logs)
113
+ - GET /requests — Recent request logs
114
+ - GET /hourly-metrics — Hourly aggregated metrics
115
+ - GET /hourly-metrics-mv — Hourly metrics (materialized view)
116
+
117
+ ### Stats (/api/v1/stats)
118
+ - GET /resources — Resource counts (gateways, services, routes, plugins)
119
+ `,
120
+ }],
121
+ })
122
+ );
123
+
124
+ // ── Dynamic Resource: Current Gateway Config ──────────────────
125
+ server.resource(
126
+ 'sopo-gateway-config',
127
+ 'sopo://gateways/current-config',
128
+ {
129
+ description: 'Live snapshot of all gateways and their configuration owned by the authenticated user.',
130
+ mimeType: 'application/json',
131
+ },
132
+ async () => {
133
+ const result = await sopoGet('/api/v1/gateways');
134
+ return {
135
+ contents: [{
136
+ uri: 'sopo://gateways/current-config',
137
+ mimeType: 'application/json',
138
+ text: JSON.stringify(result.success ? result.data : { error: result.error }, null, 2),
139
+ }],
140
+ };
141
+ }
142
+ );
143
+
144
+ // ── Dynamic Resource: Current Stats ───────────────────────────
145
+ server.resource(
146
+ 'sopo-resource-stats',
147
+ 'sopo://stats/resources',
148
+ {
149
+ description: 'Live resource counts for the authenticated user (gateways, services, routes, plugins).',
150
+ mimeType: 'application/json',
151
+ },
152
+ async () => {
153
+ const result = await sopoGet('/api/v1/stats/resources');
154
+ return {
155
+ contents: [{
156
+ uri: 'sopo://stats/resources',
157
+ mimeType: 'application/json',
158
+ text: JSON.stringify(result.success ? result.data : { error: result.error }, null, 2),
159
+ }],
160
+ };
161
+ }
162
+ );
163
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Sopo MCP Server — HTTP Client
3
+ *
4
+ * Handles all communication with the Sopo Backend REST API.
5
+ * Manages authentication headers and error normalization.
6
+ */
7
+
8
+ export interface SopoConfig {
9
+ backendUrl: string;
10
+ accessToken: string;
11
+ }
12
+
13
+ export interface SopoResponse<T = any> {
14
+ success: boolean;
15
+ data?: T;
16
+ error?: string;
17
+ statusCode: number;
18
+ }
19
+
20
+ let config: SopoConfig | null = null;
21
+ let storedRefreshToken: string | null = null;
22
+
23
+ export function configureSopoClient(cfg: SopoConfig): void {
24
+ config = cfg;
25
+ }
26
+
27
+ /**
28
+ * Updates the access token at runtime (e.g., after login or token refresh).
29
+ * All subsequent API calls will use the new token.
30
+ */
31
+ export function updateAccessToken(newToken: string): void {
32
+ if (!config) {
33
+ throw new Error('Sopo client not configured. Call configureSopoClient() first.');
34
+ }
35
+ config.accessToken = newToken;
36
+ }
37
+
38
+ /**
39
+ * Stores the refresh token in memory for automatic token refresh.
40
+ */
41
+ export function setRefreshToken(token: string): void {
42
+ storedRefreshToken = token;
43
+ }
44
+
45
+ /**
46
+ * Retrieves the stored refresh token (if any).
47
+ */
48
+ export function getRefreshToken(): string | null {
49
+ return storedRefreshToken;
50
+ }
51
+
52
+ function getConfig(): SopoConfig {
53
+ if (!config) {
54
+ throw new Error('Sopo client not configured. Call configureSopoClient() first.');
55
+ }
56
+ return config;
57
+ }
58
+
59
+ /**
60
+ * Returns the backend base URL for direct fetch calls (e.g., auth endpoints).
61
+ */
62
+ export function getBackendUrl(): string {
63
+ return getConfig().backendUrl;
64
+ }
65
+
66
+ function buildHeaders(): Record<string, string> {
67
+ const cfg = getConfig();
68
+ return {
69
+ 'Content-Type': 'application/json',
70
+ 'Authorization': cfg.accessToken.startsWith('Bearer ')
71
+ ? cfg.accessToken
72
+ : `Bearer ${cfg.accessToken}`,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Performs a GET request to the Sopo Backend.
78
+ */
79
+ export async function sopoGet<T = any>(path: string): Promise<SopoResponse<T>> {
80
+ const cfg = getConfig();
81
+ const url = `${cfg.backendUrl}${path}`;
82
+
83
+ try {
84
+ const response = await fetch(url, {
85
+ method: 'GET',
86
+ headers: buildHeaders(),
87
+ });
88
+
89
+ const body = await response.json().catch(() => null);
90
+
91
+ if (!response.ok) {
92
+ const errorMsg = body?.error?.message || body?.message || `HTTP ${response.status}`;
93
+ return { success: false, error: errorMsg, statusCode: response.status };
94
+ }
95
+
96
+ return { success: true, data: body as T, statusCode: response.status };
97
+ } catch (err: any) {
98
+ return { success: false, error: err.message || 'Network error', statusCode: 0 };
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Performs a POST request to the Sopo Backend.
104
+ */
105
+ export async function sopoPost<T = any>(path: string, body: any): Promise<SopoResponse<T>> {
106
+ const cfg = getConfig();
107
+ const url = `${cfg.backendUrl}${path}`;
108
+
109
+ try {
110
+ const response = await fetch(url, {
111
+ method: 'POST',
112
+ headers: buildHeaders(),
113
+ body: JSON.stringify(body),
114
+ });
115
+
116
+ const responseBody = await response.json().catch(() => null);
117
+
118
+ if (!response.ok) {
119
+ const errorMsg = responseBody?.error?.message || responseBody?.message || `HTTP ${response.status}`;
120
+ return { success: false, error: errorMsg, statusCode: response.status };
121
+ }
122
+
123
+ return { success: true, data: responseBody as T, statusCode: response.status };
124
+ } catch (err: any) {
125
+ return { success: false, error: err.message || 'Network error', statusCode: 0 };
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Performs a PATCH request to the Sopo Backend.
131
+ */
132
+ export async function sopoPatch<T = any>(path: string, body: any): Promise<SopoResponse<T>> {
133
+ const cfg = getConfig();
134
+ const url = `${cfg.backendUrl}${path}`;
135
+
136
+ try {
137
+ const response = await fetch(url, {
138
+ method: 'PATCH',
139
+ headers: buildHeaders(),
140
+ body: JSON.stringify(body),
141
+ });
142
+
143
+ const responseBody = await response.json().catch(() => null);
144
+
145
+ if (!response.ok) {
146
+ const errorMsg = responseBody?.error?.message || responseBody?.message || `HTTP ${response.status}`;
147
+ return { success: false, error: errorMsg, statusCode: response.status };
148
+ }
149
+
150
+ return { success: true, data: responseBody as T, statusCode: response.status };
151
+ } catch (err: any) {
152
+ return { success: false, error: err.message || 'Network error', statusCode: 0 };
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Performs a DELETE request to the Sopo Backend.
158
+ */
159
+ export async function sopoDelete<T = any>(path: string): Promise<SopoResponse<T>> {
160
+ const cfg = getConfig();
161
+ const url = `${cfg.backendUrl}${path}`;
162
+
163
+ try {
164
+ const response = await fetch(url, {
165
+ method: 'DELETE',
166
+ headers: buildHeaders(),
167
+ });
168
+
169
+ const responseBody = await response.json().catch(() => null);
170
+
171
+ if (!response.ok) {
172
+ const errorMsg = responseBody?.error?.message || responseBody?.message || `HTTP ${response.status}`;
173
+ return { success: false, error: errorMsg, statusCode: response.status };
174
+ }
175
+
176
+ return { success: true, data: responseBody as T, statusCode: response.status };
177
+ } catch (err: any) {
178
+ return { success: false, error: err.message || 'Network error', statusCode: 0 };
179
+ }
180
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Sopo MCP Server — Aggregate Request Tools
3
+ *
4
+ * CRUD tools for managing Aggregate Requests (parallel sub-requests on a single route).
5
+ */
6
+
7
+ import { z } from 'zod';
8
+ import { sopoGet, sopoPost, sopoPatch, sopoDelete } from '../sopo-client.js';
9
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
+
11
+ export function registerAggregateRequestTools(server: McpServer): void {
12
+
13
+ // ── List Aggregate Requests ───────────────────────────────────
14
+ server.tool(
15
+ 'list_aggregate_requests',
16
+ 'List all aggregate request configurations. Returns id, route_id, service_id, key_name, method, target_path, and timeout.',
17
+ {},
18
+ async () => {
19
+ const result = await sopoGet('/api/v1/aggregate-requests');
20
+ if (!result.success) {
21
+ return { content: [{ type: 'text', text: `Error: ${result.error}` }], isError: true };
22
+ }
23
+ return { content: [{ type: 'text', text: JSON.stringify(result.data, null, 2) }] };
24
+ }
25
+ );
26
+
27
+ // ── Create Aggregate Request ──────────────────────────────────
28
+ server.tool(
29
+ 'create_aggregate_request',
30
+ 'Define a sub-request for parallel execution on a gateway route. Requires route_id, service_id, and key_name. Both IDs must reference existing resources.',
31
+ {
32
+ route_id: z.string().uuid().describe('UUID of the parent gateway route (must exist)'),
33
+ service_id: z.string().uuid().describe('UUID of the service to call (must exist)'),
34
+ key_name: z.string().describe('Key name for the response merge (e.g. "users")'),
35
+ method: z.string().optional().describe('HTTP method for this sub-request'),
36
+ target_path: z.string().optional().describe('Target path for this sub-request'),
37
+ timeout: z.string().optional().describe('Timeout for this sub-request'),
38
+ },
39
+ async (args) => {
40
+ const result = await sopoPost('/api/v1/aggregate-requests', args);
41
+ if (!result.success) {
42
+ return { content: [{ type: 'text', text: `Error creating aggregate request: ${result.error}` }], isError: true };
43
+ }
44
+ return { content: [{ type: 'text', text: `Aggregate request created:\n${JSON.stringify(result.data, null, 2)}` }] };
45
+ }
46
+ );
47
+
48
+ // ── Update Aggregate Request ──────────────────────────────────
49
+ server.tool(
50
+ 'update_aggregate_request',
51
+ 'Update an aggregate request configuration by UUID.',
52
+ {
53
+ id: z.string().uuid().describe('UUID of the aggregate request'),
54
+ method: z.string().optional().describe('New HTTP method'),
55
+ target_path: z.string().optional().describe('New target path'),
56
+ timeout: z.string().optional().describe('New timeout'),
57
+ },
58
+ async (args) => {
59
+ const { id, ...body } = args;
60
+ const result = await sopoPatch(`/api/v1/aggregate-requests/${id}`, body);
61
+ if (!result.success) {
62
+ return { content: [{ type: 'text', text: `Error updating aggregate request: ${result.error}` }], isError: true };
63
+ }
64
+ return { content: [{ type: 'text', text: `Aggregate request updated:\n${JSON.stringify(result.data, null, 2)}` }] };
65
+ }
66
+ );
67
+
68
+ // ── Delete Aggregate Request ──────────────────────────────────
69
+ server.tool(
70
+ 'delete_aggregate_request',
71
+ 'Delete an aggregate request by its UUID.',
72
+ {
73
+ id: z.string().uuid().describe('UUID of the aggregate request to delete'),
74
+ },
75
+ async (args) => {
76
+ const result = await sopoDelete(`/api/v1/aggregate-requests/${args.id}`);
77
+ if (!result.success) {
78
+ return { content: [{ type: 'text', text: `Error deleting aggregate request: ${result.error}` }], isError: true };
79
+ }
80
+ return { content: [{ type: 'text', text: `Aggregate request deleted successfully.` }] };
81
+ }
82
+ );
83
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Sopo MCP Server — Authentication Tools
3
+ *
4
+ * Provides login and token refresh capabilities so the AI can
5
+ * authenticate automatically without requiring manual token setup.
6
+ */
7
+
8
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
+ import { z } from 'zod';
10
+ import {
11
+ getBackendUrl,
12
+ updateAccessToken,
13
+ setRefreshToken,
14
+ getRefreshToken,
15
+ } from '../sopo-client.js';
16
+
17
+ /**
18
+ * Registers authentication tools on the MCP server.
19
+ */
20
+ export function registerAuthTools(server: McpServer): void {
21
+
22
+ // ── Login with Email & Password ──────────────────────────────
23
+ server.tool(
24
+ 'sopo_login',
25
+ 'Sign in to Sopo with email and password. On success, the access token is automatically configured for all subsequent API calls. You do NOT need to set any token manually after this.',
26
+ {
27
+ email: z.string().email().describe('User email address'),
28
+ password: z.string().min(1).describe('User password'),
29
+ },
30
+ async ({ email, password }) => {
31
+ try {
32
+ const backendUrl = getBackendUrl();
33
+ const response = await fetch(`${backendUrl}/auth/signin/email-password`, {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/json' },
36
+ body: JSON.stringify({ email, password }),
37
+ });
38
+
39
+ const body = await response.json().catch(() => null);
40
+
41
+ if (!response.ok) {
42
+ const errorMsg = body?.error?.message || body?.message || `HTTP ${response.status}`;
43
+ return {
44
+ content: [{ type: 'text' as const, text: `❌ Login failed: ${errorMsg}` }],
45
+ };
46
+ }
47
+
48
+ // Extract access token from response
49
+ const accessToken = body?.session?.accessToken;
50
+ if (!accessToken) {
51
+ return {
52
+ content: [{ type: 'text' as const, text: '❌ Login succeeded but no access token was returned. Response: ' + JSON.stringify(body) }],
53
+ };
54
+ }
55
+
56
+ // Update the client with the new access token
57
+ updateAccessToken(accessToken);
58
+
59
+ // Try to extract refresh token from response body or Set-Cookie header
60
+ const refreshTokenFromBody = body?.session?.refreshToken;
61
+ const setCookieHeader = response.headers.get('set-cookie');
62
+ let refreshTokenValue = refreshTokenFromBody;
63
+
64
+ if (!refreshTokenValue && setCookieHeader) {
65
+ // Parse sopo_refresh_token from Set-Cookie header
66
+ const match = setCookieHeader.match(/sopo_refresh_token=([^;]+)/);
67
+ if (match) {
68
+ refreshTokenValue = match[1];
69
+ }
70
+ }
71
+
72
+ if (refreshTokenValue) {
73
+ setRefreshToken(refreshTokenValue);
74
+ }
75
+
76
+ const userName = body?.session?.user?.displayName || body?.session?.user?.email || email;
77
+ const expiresIn = body?.session?.accessTokenExpiresIn;
78
+ const hasRefresh = !!refreshTokenValue;
79
+
80
+ return {
81
+ content: [{
82
+ type: 'text' as const,
83
+ text: `✅ Login successful!\n\n` +
84
+ `👤 User: ${userName}\n` +
85
+ `🔑 Access Token: configured automatically\n` +
86
+ `⏱️ Expires in: ${expiresIn ? `${expiresIn} seconds` : 'unknown'}\n` +
87
+ `🔄 Refresh Token: ${hasRefresh ? 'stored (auto-refresh available)' : 'not available (login again when token expires)'}\n\n` +
88
+ `All API calls will now use this session. You can start managing gateways, services, and routes.`,
89
+ }],
90
+ };
91
+ } catch (err: any) {
92
+ return {
93
+ content: [{ type: 'text' as const, text: `❌ Login error: ${err.message || 'Network error'}` }],
94
+ };
95
+ }
96
+ }
97
+ );
98
+
99
+ // ── Refresh Access Token ─────────────────────────────────────
100
+ server.tool(
101
+ 'sopo_refresh_token',
102
+ 'Refresh the current access token using the stored refresh token. Call this when API calls start returning 401 Unauthorized errors. The new access token is automatically configured.',
103
+ {},
104
+ async () => {
105
+ try {
106
+ const currentRefreshToken = getRefreshToken();
107
+
108
+ if (!currentRefreshToken) {
109
+ return {
110
+ content: [{
111
+ type: 'text' as const,
112
+ text: '❌ No refresh token available. Please use `sopo_login` to sign in first.',
113
+ }],
114
+ };
115
+ }
116
+
117
+ const backendUrl = getBackendUrl();
118
+ const response = await fetch(`${backendUrl}/auth/token`, {
119
+ method: 'POST',
120
+ headers: { 'Content-Type': 'application/json' },
121
+ body: JSON.stringify({ refreshToken: currentRefreshToken }),
122
+ });
123
+
124
+ const body = await response.json().catch(() => null);
125
+
126
+ if (!response.ok) {
127
+ const errorMsg = body?.error?.message || body?.message || `HTTP ${response.status}`;
128
+ // Clear the stored refresh token since it's invalid
129
+ setRefreshToken('');
130
+ return {
131
+ content: [{
132
+ type: 'text' as const,
133
+ text: `❌ Token refresh failed: ${errorMsg}\n\nPlease use \`sopo_login\` to sign in again.`,
134
+ }],
135
+ };
136
+ }
137
+
138
+ // Extract new access token
139
+ const accessToken = body?.session?.accessToken || body?.accessToken;
140
+ if (!accessToken) {
141
+ return {
142
+ content: [{
143
+ type: 'text' as const,
144
+ text: '❌ Token refresh succeeded but no access token was returned. Response: ' + JSON.stringify(body),
145
+ }],
146
+ };
147
+ }
148
+
149
+ // Update the client with the new access token
150
+ updateAccessToken(accessToken);
151
+
152
+ // Update refresh token if a new one was provided
153
+ const newRefreshToken = body?.session?.refreshToken || body?.refreshToken;
154
+ const setCookieHeader = response.headers.get('set-cookie');
155
+
156
+ if (newRefreshToken) {
157
+ setRefreshToken(newRefreshToken);
158
+ } else if (setCookieHeader) {
159
+ const match = setCookieHeader.match(/sopo_refresh_token=([^;]+)/);
160
+ if (match) {
161
+ setRefreshToken(match[1]);
162
+ }
163
+ }
164
+
165
+ const expiresIn = body?.session?.accessTokenExpiresIn || body?.accessTokenExpiresIn;
166
+
167
+ return {
168
+ content: [{
169
+ type: 'text' as const,
170
+ text: `✅ Token refreshed successfully!\n\n` +
171
+ `🔑 New Access Token: configured automatically\n` +
172
+ `⏱️ Expires in: ${expiresIn ? `${expiresIn} seconds` : 'unknown'}\n` +
173
+ `🔄 Refresh Token: updated\n\n` +
174
+ `All API calls will now use the new token.`,
175
+ }],
176
+ };
177
+ } catch (err: any) {
178
+ return {
179
+ content: [{
180
+ type: 'text' as const,
181
+ text: `❌ Token refresh error: ${err.message || 'Network error'}`,
182
+ }],
183
+ };
184
+ }
185
+ }
186
+ );
187
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Sopo MCP Server — Collection Tools
3
+ *
4
+ * CRUD tools for managing Collections (organizational groups for routes/services in Pro mode).
5
+ */
6
+
7
+ import { z } from 'zod';
8
+ import { sopoGet, sopoPost, sopoPatch, sopoDelete } from '../sopo-client.js';
9
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
+
11
+ export function registerCollectionTools(server: McpServer): void {
12
+
13
+ // ── List Collections ──────────────────────────────────────────
14
+ server.tool(
15
+ 'list_collections',
16
+ 'List all collections (organizational groupings for Pro mode gateways). Returns id, gateway_id, name, and description.',
17
+ {},
18
+ async () => {
19
+ const result = await sopoGet('/api/v1/collections');
20
+ if (!result.success) {
21
+ return { content: [{ type: 'text', text: `Error: ${result.error}` }], isError: true };
22
+ }
23
+ return { content: [{ type: 'text', text: JSON.stringify(result.data, null, 2) }] };
24
+ }
25
+ );
26
+
27
+ // ── Create Collection ─────────────────────────────────────────
28
+ server.tool(
29
+ 'create_collection',
30
+ 'Create a new collection inside a gateway (Pro mode). Requires gateway_id and name.',
31
+ {
32
+ gateway_id: z.string().uuid().describe('UUID of the parent gateway (must exist)'),
33
+ name: z.string().describe('Collection name (e.g. "v1", "auth-apis")'),
34
+ description: z.string().optional().describe('Description of the collection'),
35
+ },
36
+ async (args) => {
37
+ const result = await sopoPost('/api/v1/collections', args);
38
+ if (!result.success) {
39
+ return { content: [{ type: 'text', text: `Error creating collection: ${result.error}` }], isError: true };
40
+ }
41
+ return { content: [{ type: 'text', text: `Collection created:\n${JSON.stringify(result.data, null, 2)}` }] };
42
+ }
43
+ );
44
+
45
+ // ── Update Collection ─────────────────────────────────────────
46
+ server.tool(
47
+ 'update_collection',
48
+ 'Update a collection by UUID. You can change name, description, or is_active.',
49
+ {
50
+ id: z.string().uuid().describe('UUID of the collection'),
51
+ name: z.string().optional().describe('New name'),
52
+ description: z.string().optional().describe('New description'),
53
+ is_active: z.boolean().optional().describe('Active status'),
54
+ },
55
+ async (args) => {
56
+ const { id, ...body } = args;
57
+ const result = await sopoPatch(`/api/v1/collections/${id}`, body);
58
+ if (!result.success) {
59
+ return { content: [{ type: 'text', text: `Error updating collection: ${result.error}` }], isError: true };
60
+ }
61
+ return { content: [{ type: 'text', text: `Collection updated:\n${JSON.stringify(result.data, null, 2)}` }] };
62
+ }
63
+ );
64
+
65
+ // ── Delete Collection ─────────────────────────────────────────
66
+ server.tool(
67
+ 'delete_collection',
68
+ 'Delete a collection by its UUID.',
69
+ {
70
+ id: z.string().uuid().describe('UUID of the collection to delete'),
71
+ },
72
+ async (args) => {
73
+ const result = await sopoDelete(`/api/v1/collections/${args.id}`);
74
+ if (!result.success) {
75
+ return { content: [{ type: 'text', text: `Error deleting collection: ${result.error}` }], isError: true };
76
+ }
77
+ return { content: [{ type: 'text', text: `Collection deleted successfully.` }] };
78
+ }
79
+ );
80
+ }