@snokam/mcp-api 1.3.1 → 1.4.1
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/auth.d.ts +0 -15
- package/dist/auth.js +15 -59
- package/dist/builtin-tools.d.ts +109 -0
- package/dist/builtin-tools.js +200 -0
- package/dist/execute-call.d.ts +5 -0
- package/dist/execute-call.js +45 -0
- package/dist/index.d.ts +0 -7
- package/dist/index.js +7 -312
- package/dist/openapi-loader.d.ts +1 -14
- package/dist/openapi-loader.js +8 -49
- package/dist/state.d.ts +13 -0
- package/dist/state.js +54 -0
- package/dist/tool-schema.d.ts +2 -0
- package/dist/tool-schema.js +42 -0
- package/package.json +6 -6
- package/specs/production/accounting.json +2533 -1937
- package/specs/production/blog.json +1608 -1
- package/specs/production/broker.json +52 -24
- package/specs/production/chatgpt.json +299 -17
- package/specs/production/employees.json +3116 -278
- package/specs/production/events.json +437 -51
- package/specs/production/notifications.json +441 -11
- package/specs/production/office.json +818 -836
- package/specs/production/recruitment.json +3635 -1
- package/specs/production/sales.json +5241 -352
- package/specs/production/sanity.json +28588 -15500
- package/specs/production/sync.json +116 -20
- package/specs/production/webshop.json +38 -18
- package/specs/test/accounting.json +2533 -1937
- package/specs/test/blog.json +1608 -1
- package/specs/test/broker.json +52 -24
- package/specs/test/chatgpt.json +299 -17
- package/specs/test/employees.json +3116 -278
- package/specs/test/events.json +437 -51
- package/specs/test/notifications.json +441 -11
- package/specs/test/office.json +818 -836
- package/specs/test/recruitment.json +3635 -1
- package/specs/test/sales.json +5241 -352
- package/specs/test/sanity.json +28588 -15500
- package/specs/test/sync.json +116 -20
- package/specs/test/webshop.json +38 -18
package/dist/index.js
CHANGED
|
@@ -1,193 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Snokam MCP Server
|
|
4
|
-
*
|
|
5
|
-
* Exposes Snokam backend APIs as MCP tools by reading bundled OpenAPI specs.
|
|
6
|
-
* Auth is handled via @azure/identity — supports Azure CLI (local),
|
|
7
|
-
* managed identity (Azure), and OBO (when SNOKAM_USER_JWT is set).
|
|
8
|
-
*/
|
|
9
2
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
10
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
4
|
import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
let currentEnvironment = process.env.SNOKAM_ENVIRONMENT ?? "production";
|
|
18
|
-
let endpoints = [];
|
|
19
|
-
let endpointsByTool = new Map();
|
|
20
|
-
const serviceUrlOverrides = new Map();
|
|
21
|
-
async function loadEndpoints(environment) {
|
|
22
|
-
currentEnvironment = environment;
|
|
23
|
-
endpoints = await fetchSpecs(environment);
|
|
24
|
-
endpointsByTool = new Map();
|
|
25
|
-
for (const ep of endpoints) {
|
|
26
|
-
endpointsByTool.set(ep.toolName, ep);
|
|
27
|
-
}
|
|
28
|
-
// Re-apply any active URL overrides
|
|
29
|
-
applyUrlOverrides();
|
|
30
|
-
}
|
|
31
|
-
function applyUrlOverrides() {
|
|
32
|
-
for (const ep of endpoints) {
|
|
33
|
-
const override = serviceUrlOverrides.get(ep.service);
|
|
34
|
-
if (override) {
|
|
35
|
-
ep.baseUrl = override;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
// ---------------------------------------------------------------------------
|
|
40
|
-
// Built-in tool: SwitchEnvironment
|
|
41
|
-
// ---------------------------------------------------------------------------
|
|
42
|
-
const SWITCH_TOOL_NAME = "SwitchEnvironment";
|
|
43
|
-
const VALID_ENVIRONMENTS = ["production", "test"];
|
|
44
|
-
const switchToolDef = {
|
|
45
|
-
name: SWITCH_TOOL_NAME,
|
|
46
|
-
description: "Switch the Snokam MCP server between environments (production/test). Reloads all API specs for the new environment.",
|
|
47
|
-
inputSchema: {
|
|
48
|
-
type: "object",
|
|
49
|
-
properties: {
|
|
50
|
-
environment: {
|
|
51
|
-
type: "string",
|
|
52
|
-
enum: VALID_ENVIRONMENTS,
|
|
53
|
-
description: "The environment to switch to",
|
|
54
|
-
},
|
|
55
|
-
},
|
|
56
|
-
required: ["environment"],
|
|
57
|
-
},
|
|
58
|
-
};
|
|
59
|
-
// ---------------------------------------------------------------------------
|
|
60
|
-
// Built-in tool: SetServiceUrl
|
|
61
|
-
// ---------------------------------------------------------------------------
|
|
62
|
-
const SET_URL_TOOL_NAME = "SetServiceUrl";
|
|
63
|
-
const RESET_URL_TOOL_NAME = "ResetServiceUrl";
|
|
64
|
-
const setUrlToolDef = {
|
|
65
|
-
name: SET_URL_TOOL_NAME,
|
|
66
|
-
description: "Override a service's base URL, e.g. to point to a locally running function. Auth is skipped for localhost URLs. Use ResetServiceUrl to revert.",
|
|
67
|
-
inputSchema: {
|
|
68
|
-
type: "object",
|
|
69
|
-
properties: {
|
|
70
|
-
service: {
|
|
71
|
-
type: "string",
|
|
72
|
-
description: "The service name (e.g. employees, notifications, events)",
|
|
73
|
-
},
|
|
74
|
-
url: {
|
|
75
|
-
type: "string",
|
|
76
|
-
description: "The base URL to use (e.g. http://localhost:7071)",
|
|
77
|
-
},
|
|
78
|
-
},
|
|
79
|
-
required: ["service", "url"],
|
|
80
|
-
},
|
|
81
|
-
};
|
|
82
|
-
const resetUrlToolDef = {
|
|
83
|
-
name: RESET_URL_TOOL_NAME,
|
|
84
|
-
description: "Reset a service's base URL back to the environment default. Call without arguments to reset all overrides.",
|
|
85
|
-
inputSchema: {
|
|
86
|
-
type: "object",
|
|
87
|
-
properties: {
|
|
88
|
-
service: {
|
|
89
|
-
type: "string",
|
|
90
|
-
description: "The service name to reset. Omit to reset all overrides.",
|
|
91
|
-
},
|
|
92
|
-
},
|
|
93
|
-
},
|
|
94
|
-
};
|
|
95
|
-
// ---------------------------------------------------------------------------
|
|
96
|
-
// JSON Schema builder for tool inputs
|
|
97
|
-
// ---------------------------------------------------------------------------
|
|
98
|
-
function buildInputSchema(endpoint) {
|
|
99
|
-
const properties = {};
|
|
100
|
-
const required = [];
|
|
101
|
-
for (const param of endpoint.parameters) {
|
|
102
|
-
const prop = {};
|
|
103
|
-
if (param.schema?.type)
|
|
104
|
-
prop.type = param.schema.type;
|
|
105
|
-
if (param.schema?.enum)
|
|
106
|
-
prop.enum = param.schema.enum;
|
|
107
|
-
if (param.schema?.format)
|
|
108
|
-
prop.format = param.schema.format;
|
|
109
|
-
if (param.schema?.items)
|
|
110
|
-
prop.items = param.schema.items;
|
|
111
|
-
if (param.description)
|
|
112
|
-
prop.description = param.description;
|
|
113
|
-
if (!prop.type)
|
|
114
|
-
prop.type = "string";
|
|
115
|
-
properties[param.name] = prop;
|
|
116
|
-
if (param.required)
|
|
117
|
-
required.push(param.name);
|
|
118
|
-
}
|
|
119
|
-
if (endpoint.requestBody) {
|
|
120
|
-
properties.body = {
|
|
121
|
-
type: "object",
|
|
122
|
-
description: endpoint.requestBody.description ?? "Request body",
|
|
123
|
-
};
|
|
124
|
-
// Extract schema from content type if available
|
|
125
|
-
const jsonContent = endpoint.requestBody.content?.["application/json"];
|
|
126
|
-
if (jsonContent?.schema) {
|
|
127
|
-
properties.body = {
|
|
128
|
-
...properties.body,
|
|
129
|
-
...jsonContent.schema,
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
if (endpoint.requestBody.required)
|
|
133
|
-
required.push("body");
|
|
134
|
-
}
|
|
135
|
-
return {
|
|
136
|
-
type: "object",
|
|
137
|
-
properties,
|
|
138
|
-
required: required.length > 0 ? required : undefined,
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
// ---------------------------------------------------------------------------
|
|
142
|
-
// HTTP call execution
|
|
143
|
-
// ---------------------------------------------------------------------------
|
|
144
|
-
async function executeCall(endpoint, args) {
|
|
145
|
-
let url = `${endpoint.baseUrl}${endpoint.path}`;
|
|
146
|
-
const queryParams = [];
|
|
147
|
-
for (const param of endpoint.parameters) {
|
|
148
|
-
const value = args[param.name];
|
|
149
|
-
if (value === undefined)
|
|
150
|
-
continue;
|
|
151
|
-
if (param.in === "path") {
|
|
152
|
-
url = url.replace(`{${param.name}}`, encodeURIComponent(String(value)));
|
|
153
|
-
}
|
|
154
|
-
else if (param.in === "query") {
|
|
155
|
-
queryParams.push(`${encodeURIComponent(param.name)}=${encodeURIComponent(String(value))}`);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
if (queryParams.length > 0) {
|
|
159
|
-
url += `?${queryParams.join("&")}`;
|
|
160
|
-
}
|
|
161
|
-
const headers = {
|
|
162
|
-
Accept: "application/json",
|
|
163
|
-
};
|
|
164
|
-
const token = await getAccessToken(endpoint.scope);
|
|
165
|
-
if (token) {
|
|
166
|
-
headers.Authorization = `Bearer ${token}`;
|
|
167
|
-
}
|
|
168
|
-
let fetchBody;
|
|
169
|
-
if (args.body !== undefined && endpoint.method !== "GET") {
|
|
170
|
-
headers["Content-Type"] = "application/json";
|
|
171
|
-
fetchBody = JSON.stringify(args.body);
|
|
172
|
-
}
|
|
173
|
-
const response = await fetch(url, {
|
|
174
|
-
method: endpoint.method,
|
|
175
|
-
headers,
|
|
176
|
-
body: fetchBody,
|
|
177
|
-
});
|
|
178
|
-
let body;
|
|
179
|
-
const contentType = response.headers.get("content-type") ?? "";
|
|
180
|
-
if (contentType.includes("application/json")) {
|
|
181
|
-
body = await response.json();
|
|
182
|
-
}
|
|
183
|
-
else {
|
|
184
|
-
body = await response.text();
|
|
185
|
-
}
|
|
186
|
-
return { status: response.status, body };
|
|
187
|
-
}
|
|
188
|
-
// ---------------------------------------------------------------------------
|
|
189
|
-
// Server setup
|
|
190
|
-
// ---------------------------------------------------------------------------
|
|
5
|
+
import { BUILTIN_TOOL_DEFS, BUILTIN_TOOL_NAMES, handleBuiltinTool, } from "./builtin-tools.js";
|
|
6
|
+
import { executeCall } from "./execute-call.js";
|
|
7
|
+
import { currentEnvironment, endpoints, endpointsByTool, loadEndpoints, } from "./state.js";
|
|
8
|
+
import { buildInputSchema } from "./tool-schema.js";
|
|
191
9
|
async function main() {
|
|
192
10
|
await loadEndpoints(currentEnvironment);
|
|
193
11
|
if (endpoints.length === 0) {
|
|
@@ -202,7 +20,6 @@ async function main() {
|
|
|
202
20
|
resources: {},
|
|
203
21
|
},
|
|
204
22
|
});
|
|
205
|
-
// List resources
|
|
206
23
|
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
207
24
|
resources: [
|
|
208
25
|
{
|
|
@@ -213,11 +30,9 @@ async function main() {
|
|
|
213
30
|
},
|
|
214
31
|
],
|
|
215
32
|
}));
|
|
216
|
-
// Read resource
|
|
217
33
|
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
218
34
|
const { uri } = request.params;
|
|
219
35
|
if (uri === "snokam://about") {
|
|
220
|
-
// Group endpoints by service with their descriptions
|
|
221
36
|
const serviceMap = new Map();
|
|
222
37
|
for (const ep of endpoints) {
|
|
223
38
|
const existing = serviceMap.get(ep.service);
|
|
@@ -289,12 +104,9 @@ Controls: Sonos speakers, lights, YouTube queue
|
|
|
289
104
|
isError: true,
|
|
290
105
|
};
|
|
291
106
|
});
|
|
292
|
-
// List tools
|
|
293
107
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
294
108
|
tools: [
|
|
295
|
-
|
|
296
|
-
setUrlToolDef,
|
|
297
|
-
resetUrlToolDef,
|
|
109
|
+
...BUILTIN_TOOL_DEFS,
|
|
298
110
|
...endpoints.map((ep) => ({
|
|
299
111
|
name: ep.toolName,
|
|
300
112
|
description: ep.description || ep.summary || `${ep.method} ${ep.path}`,
|
|
@@ -302,127 +114,10 @@ Controls: Sonos speakers, lights, YouTube queue
|
|
|
302
114
|
})),
|
|
303
115
|
],
|
|
304
116
|
}));
|
|
305
|
-
// Call tool
|
|
306
117
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
307
118
|
const { name, arguments: args = {} } = request.params;
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const env = String(args.environment ?? "");
|
|
311
|
-
if (!VALID_ENVIRONMENTS.includes(env)) {
|
|
312
|
-
return {
|
|
313
|
-
content: [
|
|
314
|
-
{
|
|
315
|
-
type: "text",
|
|
316
|
-
text: `Invalid environment: ${env}. Must be one of: ${VALID_ENVIRONMENTS.join(", ")}`,
|
|
317
|
-
},
|
|
318
|
-
],
|
|
319
|
-
isError: true,
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
if (env === currentEnvironment) {
|
|
323
|
-
return {
|
|
324
|
-
content: [
|
|
325
|
-
{
|
|
326
|
-
type: "text",
|
|
327
|
-
text: `Already connected to ${env} (${endpoints.length} endpoints)`,
|
|
328
|
-
},
|
|
329
|
-
],
|
|
330
|
-
};
|
|
331
|
-
}
|
|
332
|
-
await loadEndpoints(env);
|
|
333
|
-
// Notify client that the tool list has changed
|
|
334
|
-
await server.sendToolListChanged();
|
|
335
|
-
return {
|
|
336
|
-
content: [
|
|
337
|
-
{
|
|
338
|
-
type: "text",
|
|
339
|
-
text: `Switched to ${env} environment. Loaded ${endpoints.length} endpoints.`,
|
|
340
|
-
},
|
|
341
|
-
],
|
|
342
|
-
};
|
|
343
|
-
}
|
|
344
|
-
// Handle SetServiceUrl
|
|
345
|
-
if (name === SET_URL_TOOL_NAME) {
|
|
346
|
-
const service = String(args.service ?? "");
|
|
347
|
-
const url = String(args.url ?? "");
|
|
348
|
-
const isLocal = url.startsWith("http://localhost") ||
|
|
349
|
-
url.startsWith("http://127.0.0.1");
|
|
350
|
-
// For localhost URLs, fetch the live swagger spec from the local function
|
|
351
|
-
// to pick up any new/changed endpoints during development
|
|
352
|
-
if (isLocal) {
|
|
353
|
-
try {
|
|
354
|
-
const liveEndpoints = await fetchSpecFromUrl(service, url);
|
|
355
|
-
// Remove old endpoints for this service
|
|
356
|
-
endpoints = endpoints.filter((ep) => ep.service !== service);
|
|
357
|
-
// Add the fresh ones
|
|
358
|
-
endpoints.push(...liveEndpoints);
|
|
359
|
-
// Rebuild lookup map
|
|
360
|
-
endpointsByTool = new Map();
|
|
361
|
-
for (const ep of endpoints) {
|
|
362
|
-
endpointsByTool.set(ep.toolName, ep);
|
|
363
|
-
}
|
|
364
|
-
serviceUrlOverrides.set(service, url);
|
|
365
|
-
await server.sendToolListChanged();
|
|
366
|
-
return {
|
|
367
|
-
content: [
|
|
368
|
-
{
|
|
369
|
-
type: "text",
|
|
370
|
-
text: `Overrode ${service} → ${url}. Loaded ${liveEndpoints.length} endpoints from local swagger.`,
|
|
371
|
-
},
|
|
372
|
-
],
|
|
373
|
-
};
|
|
374
|
-
}
|
|
375
|
-
catch (error) {
|
|
376
|
-
// Fall through to static override if swagger fetch fails
|
|
377
|
-
console.error(`[snokam-mcp] Failed to fetch swagger from ${url}, falling back to static spec:`, error instanceof Error ? error.message : error);
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
// Static override: just change the base URL on existing endpoints
|
|
381
|
-
const serviceEndpoints = endpoints.filter((ep) => ep.service === service);
|
|
382
|
-
if (serviceEndpoints.length === 0) {
|
|
383
|
-
const available = [
|
|
384
|
-
...new Set(endpoints.map((ep) => ep.service)),
|
|
385
|
-
].sort();
|
|
386
|
-
return {
|
|
387
|
-
content: [
|
|
388
|
-
{
|
|
389
|
-
type: "text",
|
|
390
|
-
text: `Unknown service: ${service}. Available: ${available.join(", ")}`,
|
|
391
|
-
},
|
|
392
|
-
],
|
|
393
|
-
isError: true,
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
|
-
serviceUrlOverrides.set(service, url);
|
|
397
|
-
for (const ep of serviceEndpoints) {
|
|
398
|
-
ep.baseUrl = url;
|
|
399
|
-
}
|
|
400
|
-
return {
|
|
401
|
-
content: [
|
|
402
|
-
{
|
|
403
|
-
type: "text",
|
|
404
|
-
text: `Overrode ${service} → ${url} (${serviceEndpoints.length} endpoints).`,
|
|
405
|
-
},
|
|
406
|
-
],
|
|
407
|
-
};
|
|
408
|
-
}
|
|
409
|
-
// Handle ResetServiceUrl
|
|
410
|
-
if (name === RESET_URL_TOOL_NAME) {
|
|
411
|
-
const service = args.service ? String(args.service) : undefined;
|
|
412
|
-
if (service) {
|
|
413
|
-
serviceUrlOverrides.delete(service);
|
|
414
|
-
}
|
|
415
|
-
else {
|
|
416
|
-
serviceUrlOverrides.clear();
|
|
417
|
-
}
|
|
418
|
-
// Reload to restore original URLs
|
|
419
|
-
await loadEndpoints(currentEnvironment);
|
|
420
|
-
const msg = service
|
|
421
|
-
? `Reset ${service} to environment default`
|
|
422
|
-
: `Reset all service URL overrides`;
|
|
423
|
-
return {
|
|
424
|
-
content: [{ type: "text", text: msg }],
|
|
425
|
-
};
|
|
119
|
+
if (BUILTIN_TOOL_NAMES.has(name)) {
|
|
120
|
+
return handleBuiltinTool(server, name, args);
|
|
426
121
|
}
|
|
427
122
|
const endpoint = endpointsByTool.get(name);
|
|
428
123
|
if (!endpoint) {
|
package/dist/openapi-loader.d.ts
CHANGED
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fetch OpenAPI specs from live Snokam APIs and produce tool definitions.
|
|
3
|
-
*
|
|
4
|
-
* Each backend function is available at `{service}.api.snokam.no` (prod)
|
|
5
|
-
* or `{service}.api.test.snokam.no` (test). The loader fetches `/swagger.json`
|
|
6
|
-
* from each, extracts endpoints, parameters, and OAuth scopes to generate
|
|
7
|
-
* MCP-compatible tool metadata.
|
|
8
|
-
*/
|
|
9
1
|
export interface ApiEndpoint {
|
|
10
2
|
service: string;
|
|
11
3
|
serviceDescription: string;
|
|
@@ -18,7 +10,6 @@ export interface ApiEndpoint {
|
|
|
18
10
|
description: string;
|
|
19
11
|
parameters: OpenApiParameter[];
|
|
20
12
|
requestBody: OpenApiRequestBody | null;
|
|
21
|
-
/** OAuth2 scope in `.default` format for OBO exchange, or null for public endpoints. */
|
|
22
13
|
scope: string | null;
|
|
23
14
|
}
|
|
24
15
|
interface OpenApiParameter {
|
|
@@ -42,10 +33,6 @@ interface OpenApiRequestBody {
|
|
|
42
33
|
schema?: Record<string, unknown>;
|
|
43
34
|
}>;
|
|
44
35
|
}
|
|
45
|
-
/**
|
|
46
|
-
* Fetch a swagger spec from a specific URL (e.g. a locally running function)
|
|
47
|
-
* and return parsed endpoints for that service.
|
|
48
|
-
*/
|
|
49
36
|
export declare function fetchSpecFromUrl(service: string, baseUrl: string): Promise<ApiEndpoint[]>;
|
|
50
|
-
export declare function fetchSpecs(environment: string): Promise<ApiEndpoint[]>;
|
|
37
|
+
export declare function fetchSpecs(environment: string, apiHost: string): Promise<ApiEndpoint[]>;
|
|
51
38
|
export {};
|
package/dist/openapi-loader.js
CHANGED
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fetch OpenAPI specs from live Snokam APIs and produce tool definitions.
|
|
3
|
-
*
|
|
4
|
-
* Each backend function is available at `{service}.api.snokam.no` (prod)
|
|
5
|
-
* or `{service}.api.test.snokam.no` (test). The loader fetches `/swagger.json`
|
|
6
|
-
* from each, extracts endpoints, parameters, and OAuth scopes to generate
|
|
7
|
-
* MCP-compatible tool metadata.
|
|
8
|
-
*/
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
// Service discovery - reads from bundled specs directory at runtime
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
1
|
async function discoverServices(environment) {
|
|
13
2
|
try {
|
|
14
3
|
const { readdir } = await import("fs/promises");
|
|
@@ -23,24 +12,12 @@ async function discoverServices(environment) {
|
|
|
23
12
|
.sort();
|
|
24
13
|
}
|
|
25
14
|
catch {
|
|
26
|
-
// No bundled specs found - return empty array
|
|
27
15
|
return [];
|
|
28
16
|
}
|
|
29
17
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
// ---------------------------------------------------------------------------
|
|
33
|
-
const PROD_DOMAIN = "api.snokam.no";
|
|
34
|
-
const TEST_DOMAIN = "api.test.snokam.no";
|
|
35
|
-
function getBaseDomain(environment) {
|
|
36
|
-
return environment === "test" ? TEST_DOMAIN : PROD_DOMAIN;
|
|
18
|
+
function getBaseUrl(service, apiHost) {
|
|
19
|
+
return `https://${apiHost}/api/${service}`;
|
|
37
20
|
}
|
|
38
|
-
function getBaseUrl(service, environment) {
|
|
39
|
-
return `https://${service}.${getBaseDomain(environment)}`;
|
|
40
|
-
}
|
|
41
|
-
// ---------------------------------------------------------------------------
|
|
42
|
-
// Scope extraction
|
|
43
|
-
// ---------------------------------------------------------------------------
|
|
44
21
|
function extractScope(operation) {
|
|
45
22
|
for (const secReq of operation.security ?? []) {
|
|
46
23
|
for (const scopes of Object.values(secReq)) {
|
|
@@ -56,15 +33,9 @@ function extractScope(operation) {
|
|
|
56
33
|
}
|
|
57
34
|
return null;
|
|
58
35
|
}
|
|
59
|
-
// ---------------------------------------------------------------------------
|
|
60
|
-
// Tool naming
|
|
61
|
-
// ---------------------------------------------------------------------------
|
|
62
36
|
function makeToolName(service, operationId) {
|
|
63
37
|
return `${service}__${operationId}`;
|
|
64
38
|
}
|
|
65
|
-
// ---------------------------------------------------------------------------
|
|
66
|
-
// Spec parsing
|
|
67
|
-
// ---------------------------------------------------------------------------
|
|
68
39
|
function parseSpec(spec, service, baseUrl) {
|
|
69
40
|
const endpoints = [];
|
|
70
41
|
const paths = spec.paths;
|
|
@@ -98,13 +69,6 @@ function parseSpec(spec, service, baseUrl) {
|
|
|
98
69
|
}
|
|
99
70
|
return endpoints;
|
|
100
71
|
}
|
|
101
|
-
// ---------------------------------------------------------------------------
|
|
102
|
-
// Public API
|
|
103
|
-
// ---------------------------------------------------------------------------
|
|
104
|
-
/**
|
|
105
|
-
* Fetch a swagger spec from a specific URL (e.g. a locally running function)
|
|
106
|
-
* and return parsed endpoints for that service.
|
|
107
|
-
*/
|
|
108
72
|
export async function fetchSpecFromUrl(service, baseUrl) {
|
|
109
73
|
const swaggerUrl = `${baseUrl}/swagger.json`;
|
|
110
74
|
const response = await fetch(swaggerUrl, {
|
|
@@ -116,10 +80,9 @@ export async function fetchSpecFromUrl(service, baseUrl) {
|
|
|
116
80
|
const spec = (await response.json());
|
|
117
81
|
return parseSpec(spec, service, baseUrl);
|
|
118
82
|
}
|
|
119
|
-
export async function fetchSpecs(environment) {
|
|
120
|
-
// Try to load bundled specs first (for speed)
|
|
83
|
+
export async function fetchSpecs(environment, apiHost) {
|
|
121
84
|
try {
|
|
122
|
-
const bundledSpecs = await loadBundledSpecs(environment);
|
|
85
|
+
const bundledSpecs = await loadBundledSpecs(environment, apiHost);
|
|
123
86
|
if (bundledSpecs.length > 0) {
|
|
124
87
|
console.error(`[snokam-mcp] Loaded ${bundledSpecs.length} endpoints from bundled specs (env=${environment})`);
|
|
125
88
|
return bundledSpecs;
|
|
@@ -128,8 +91,6 @@ export async function fetchSpecs(environment) {
|
|
|
128
91
|
catch (error) {
|
|
129
92
|
console.error(`[snokam-mcp] Failed to load bundled specs, falling back to live fetch:`, error);
|
|
130
93
|
}
|
|
131
|
-
// Fallback to live fetching (slower, for development)
|
|
132
|
-
// Discover services from environment to know which APIs to fetch
|
|
133
94
|
const services = await discoverServices(environment);
|
|
134
95
|
if (services.length === 0) {
|
|
135
96
|
console.error("[snokam-mcp] No services discovered. Unable to fetch specs.");
|
|
@@ -137,7 +98,7 @@ export async function fetchSpecs(environment) {
|
|
|
137
98
|
}
|
|
138
99
|
const endpoints = [];
|
|
139
100
|
const results = await Promise.allSettled(services.map(async (service) => {
|
|
140
|
-
const baseUrl = getBaseUrl(service,
|
|
101
|
+
const baseUrl = getBaseUrl(service, apiHost);
|
|
141
102
|
const swaggerUrl = `${baseUrl}/swagger.json`;
|
|
142
103
|
const response = await fetch(swaggerUrl, {
|
|
143
104
|
signal: AbortSignal.timeout(10_000),
|
|
@@ -163,13 +124,12 @@ export async function fetchSpecs(environment) {
|
|
|
163
124
|
console.error(`[snokam-mcp] Loaded ${endpoints.length} endpoints from ${successCount}/${services.length} services (env=${environment})`);
|
|
164
125
|
return endpoints;
|
|
165
126
|
}
|
|
166
|
-
async function loadBundledSpecs(environment) {
|
|
127
|
+
async function loadBundledSpecs(environment, apiHost) {
|
|
167
128
|
const { readFile } = await import("fs/promises");
|
|
168
129
|
const { fileURLToPath } = await import("url");
|
|
169
130
|
const { dirname, join } = await import("path");
|
|
170
131
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
171
132
|
const specsDir = join(__dirname, "..", "specs", environment);
|
|
172
|
-
// Discover which specs are bundled
|
|
173
133
|
const services = await discoverServices(environment);
|
|
174
134
|
const endpoints = [];
|
|
175
135
|
for (const service of services) {
|
|
@@ -177,11 +137,10 @@ async function loadBundledSpecs(environment) {
|
|
|
177
137
|
const specPath = join(specsDir, `${service}.json`);
|
|
178
138
|
const specData = await readFile(specPath, "utf-8");
|
|
179
139
|
const spec = JSON.parse(specData);
|
|
180
|
-
const baseUrl = getBaseUrl(service,
|
|
140
|
+
const baseUrl = getBaseUrl(service, apiHost);
|
|
181
141
|
endpoints.push(...parseSpec(spec, service, baseUrl));
|
|
182
142
|
}
|
|
183
|
-
catch
|
|
184
|
-
// Spec file doesn't exist or is invalid, skip
|
|
143
|
+
catch {
|
|
185
144
|
continue;
|
|
186
145
|
}
|
|
187
146
|
}
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ApiEndpoint } from "./openapi-loader.js";
|
|
2
|
+
export declare let currentEnvironment: string;
|
|
3
|
+
export declare let apiHostOverride: string | null;
|
|
4
|
+
export declare let endpoints: ApiEndpoint[];
|
|
5
|
+
export declare let endpointsByTool: Map<string, ApiEndpoint>;
|
|
6
|
+
export declare const serviceUrlOverrides: Map<string, string>;
|
|
7
|
+
export declare function setApiHostOverride(host: string | null): void;
|
|
8
|
+
export declare function resolveApiHost(environment: string): string;
|
|
9
|
+
export declare function activeApiHost(): string;
|
|
10
|
+
export declare function loadEndpoints(environment: string): Promise<void>;
|
|
11
|
+
export declare function normalizeDomain(raw: string): string;
|
|
12
|
+
export declare function rebuildEndpointsByTool(): void;
|
|
13
|
+
export declare function replaceServiceEndpoints(service: string, freshEndpoints: ApiEndpoint[]): void;
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { fetchSpecs } from "./openapi-loader.js";
|
|
2
|
+
export let currentEnvironment = process.env.SNOKAM_ENVIRONMENT ?? "production";
|
|
3
|
+
export let apiHostOverride = null;
|
|
4
|
+
export let endpoints = [];
|
|
5
|
+
export let endpointsByTool = new Map();
|
|
6
|
+
export const serviceUrlOverrides = new Map();
|
|
7
|
+
export function setApiHostOverride(host) {
|
|
8
|
+
apiHostOverride = host;
|
|
9
|
+
}
|
|
10
|
+
export function resolveApiHost(environment) {
|
|
11
|
+
const explicit = process.env.SNOKAM_API_HOST;
|
|
12
|
+
if (explicit)
|
|
13
|
+
return normalizeDomain(explicit);
|
|
14
|
+
const suffix = environment === "test" ? "test.snosky.no" : "snosky.no";
|
|
15
|
+
const business = process.env.SNOKAM_BUSINESS;
|
|
16
|
+
return business ? `${business}.${suffix}` : suffix;
|
|
17
|
+
}
|
|
18
|
+
export function activeApiHost() {
|
|
19
|
+
return apiHostOverride ?? resolveApiHost(currentEnvironment);
|
|
20
|
+
}
|
|
21
|
+
export async function loadEndpoints(environment) {
|
|
22
|
+
currentEnvironment = environment;
|
|
23
|
+
endpoints = await fetchSpecs(environment, activeApiHost());
|
|
24
|
+
endpointsByTool = new Map();
|
|
25
|
+
for (const ep of endpoints) {
|
|
26
|
+
endpointsByTool.set(ep.toolName, ep);
|
|
27
|
+
}
|
|
28
|
+
applyUrlOverrides();
|
|
29
|
+
}
|
|
30
|
+
function applyUrlOverrides() {
|
|
31
|
+
for (const ep of endpoints) {
|
|
32
|
+
const override = serviceUrlOverrides.get(ep.service);
|
|
33
|
+
if (override) {
|
|
34
|
+
ep.baseUrl = override;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function normalizeDomain(raw) {
|
|
39
|
+
return raw
|
|
40
|
+
.trim()
|
|
41
|
+
.replace(/^https?:\/\//, "")
|
|
42
|
+
.replace(/\/$/, "");
|
|
43
|
+
}
|
|
44
|
+
export function rebuildEndpointsByTool() {
|
|
45
|
+
endpointsByTool = new Map();
|
|
46
|
+
for (const ep of endpoints) {
|
|
47
|
+
endpointsByTool.set(ep.toolName, ep);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function replaceServiceEndpoints(service, freshEndpoints) {
|
|
51
|
+
endpoints = endpoints.filter((ep) => ep.service !== service);
|
|
52
|
+
endpoints.push(...freshEndpoints);
|
|
53
|
+
rebuildEndpointsByTool();
|
|
54
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function buildInputSchema(endpoint) {
|
|
2
|
+
const properties = {};
|
|
3
|
+
const required = [];
|
|
4
|
+
for (const param of endpoint.parameters) {
|
|
5
|
+
const prop = {};
|
|
6
|
+
if (param.schema?.type)
|
|
7
|
+
prop.type = param.schema.type;
|
|
8
|
+
if (param.schema?.enum)
|
|
9
|
+
prop.enum = param.schema.enum;
|
|
10
|
+
if (param.schema?.format)
|
|
11
|
+
prop.format = param.schema.format;
|
|
12
|
+
if (param.schema?.items)
|
|
13
|
+
prop.items = param.schema.items;
|
|
14
|
+
if (param.description)
|
|
15
|
+
prop.description = param.description;
|
|
16
|
+
if (!prop.type)
|
|
17
|
+
prop.type = "string";
|
|
18
|
+
properties[param.name] = prop;
|
|
19
|
+
if (param.required)
|
|
20
|
+
required.push(param.name);
|
|
21
|
+
}
|
|
22
|
+
if (endpoint.requestBody) {
|
|
23
|
+
properties.body = {
|
|
24
|
+
type: "object",
|
|
25
|
+
description: endpoint.requestBody.description ?? "Request body",
|
|
26
|
+
};
|
|
27
|
+
const jsonContent = endpoint.requestBody.content?.["application/json"];
|
|
28
|
+
if (jsonContent?.schema) {
|
|
29
|
+
properties.body = {
|
|
30
|
+
...properties.body,
|
|
31
|
+
...jsonContent.schema,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
if (endpoint.requestBody.required)
|
|
35
|
+
required.push("body");
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
type: "object",
|
|
39
|
+
properties,
|
|
40
|
+
required: required.length > 0 ? required : undefined,
|
|
41
|
+
};
|
|
42
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@snokam/mcp-api",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "MCP server exposing Snokam backend APIs as tools for Claude Code and other MCP clients",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,13 +21,13 @@
|
|
|
21
21
|
"specs"
|
|
22
22
|
],
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@azure/identity": "^4.
|
|
25
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
26
|
-
"zod": "^
|
|
24
|
+
"@azure/identity": "^4.13.1",
|
|
25
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
26
|
+
"zod": "^4.4.3"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
|
-
"@types/node": "^
|
|
30
|
-
"typescript": "~
|
|
29
|
+
"@types/node": "^26.1.1",
|
|
30
|
+
"typescript": "~6.0.2"
|
|
31
31
|
},
|
|
32
32
|
"publishConfig": {
|
|
33
33
|
"access": "public"
|