@snokam/mcp-api 1.21.1 → 2.0.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 +17 -61
- 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 +16 -311
- package/dist/openapi-loader.d.ts +1 -14
- package/dist/openapi-loader.js +27 -50
- package/dist/requirements.d.ts +10 -0
- package/dist/requirements.js +92 -0
- package/dist/state.d.ts +14 -0
- package/dist/state.js +66 -0
- package/dist/tool-schema.d.ts +2 -0
- package/dist/tool-schema.js +42 -0
- package/package.json +3 -3
- package/specs/production/accounting.json +2629 -1754
- 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 +2979 -299
- package/specs/production/events.json +366 -52
- package/specs/production/notifications.json +390 -13
- package/specs/production/office.json +818 -836
- package/specs/production/recruitment.json +3635 -1
- package/specs/production/sales.json +5088 -393
- package/specs/production/sanity.json +29008 -15539
- package/specs/production/sync.json +116 -20
- package/specs/production/webshop.json +38 -18
- package/specs/test/accounting.json +2629 -1754
- 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 +2979 -299
- package/specs/test/events.json +366 -52
- package/specs/test/notifications.json +390 -13
- package/specs/test/office.json +818 -836
- package/specs/test/recruitment.json +3635 -1
- package/specs/test/sales.json +5088 -393
- package/specs/test/sanity.json +29008 -15539
- package/specs/test/sync.json +116 -20
- package/specs/test/webshop.json +38 -18
package/dist/index.js
CHANGED
|
@@ -1,194 +1,15 @@
|
|
|
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
|
-
|
|
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 { settled } from "./state.js";
|
|
8
|
+
import { currentEnvironment, endpoints, endpointsByTool, loadEndpoints, } from "./state.js";
|
|
9
|
+
import { buildInputSchema } from "./tool-schema.js";
|
|
10
|
+
import { blockedReason, reportRequirements } from "./requirements.js";
|
|
191
11
|
async function main() {
|
|
12
|
+
reportRequirements();
|
|
192
13
|
await loadEndpoints(currentEnvironment);
|
|
193
14
|
if (endpoints.length === 0) {
|
|
194
15
|
console.error("[snokam-mcp] No endpoints loaded. Ensure specs/*.json files exist.");
|
|
@@ -202,7 +23,6 @@ async function main() {
|
|
|
202
23
|
resources: {},
|
|
203
24
|
},
|
|
204
25
|
});
|
|
205
|
-
// List resources
|
|
206
26
|
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
207
27
|
resources: [
|
|
208
28
|
{
|
|
@@ -213,11 +33,9 @@ async function main() {
|
|
|
213
33
|
},
|
|
214
34
|
],
|
|
215
35
|
}));
|
|
216
|
-
// Read resource
|
|
217
36
|
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
218
37
|
const { uri } = request.params;
|
|
219
38
|
if (uri === "snokam://about") {
|
|
220
|
-
// Group endpoints by service with their descriptions
|
|
221
39
|
const serviceMap = new Map();
|
|
222
40
|
for (const ep of endpoints) {
|
|
223
41
|
const existing = serviceMap.get(ep.service);
|
|
@@ -289,12 +107,9 @@ Controls: Sonos speakers, lights, YouTube queue
|
|
|
289
107
|
isError: true,
|
|
290
108
|
};
|
|
291
109
|
});
|
|
292
|
-
// List tools
|
|
293
110
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
294
111
|
tools: [
|
|
295
|
-
|
|
296
|
-
setUrlToolDef,
|
|
297
|
-
resetUrlToolDef,
|
|
112
|
+
...BUILTIN_TOOL_DEFS,
|
|
298
113
|
...endpoints.map((ep) => ({
|
|
299
114
|
name: ep.toolName,
|
|
300
115
|
description: ep.description || ep.summary || `${ep.method} ${ep.path}`,
|
|
@@ -302,127 +117,17 @@ Controls: Sonos speakers, lights, YouTube queue
|
|
|
302
117
|
})),
|
|
303
118
|
],
|
|
304
119
|
}));
|
|
305
|
-
// Call tool
|
|
306
120
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
307
121
|
const { name, arguments: args = {} } = request.params;
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
};
|
|
122
|
+
if (BUILTIN_TOOL_NAMES.has(name)) {
|
|
123
|
+
const result = await handleBuiltinTool(server, name, args);
|
|
124
|
+
reportRequirements();
|
|
125
|
+
return result;
|
|
343
126
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
};
|
|
127
|
+
await settled();
|
|
128
|
+
const blocked = blockedReason();
|
|
129
|
+
if (blocked) {
|
|
130
|
+
return { content: [{ type: "text", text: blocked }], isError: true };
|
|
426
131
|
}
|
|
427
132
|
const endpoint = endpointsByTool.get(name);
|
|
428
133
|
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,27 @@ 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
|
-
|
|
67
|
-
|
|
39
|
+
function resourceOf(path) {
|
|
40
|
+
const segment = path
|
|
41
|
+
.split("/")
|
|
42
|
+
.find((part) => part && !part.startsWith("{") && !/^v[0-9]/.test(part));
|
|
43
|
+
return segment ? segment.replace(/[^a-zA-Z0-9]/g, "") : "";
|
|
44
|
+
}
|
|
45
|
+
function disambiguate(endpoints) {
|
|
46
|
+
const counts = new Map();
|
|
47
|
+
for (const ep of endpoints) {
|
|
48
|
+
counts.set(ep.toolName, (counts.get(ep.toolName) ?? 0) + 1);
|
|
49
|
+
}
|
|
50
|
+
return endpoints.map((ep) => {
|
|
51
|
+
if ((counts.get(ep.toolName) ?? 0) < 2)
|
|
52
|
+
return ep;
|
|
53
|
+
const resource = resourceOf(ep.path);
|
|
54
|
+
return resource ? { ...ep, toolName: `${ep.toolName}_${resource}` } : ep;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
68
57
|
function parseSpec(spec, service, baseUrl) {
|
|
69
58
|
const endpoints = [];
|
|
70
59
|
const paths = spec.paths;
|
|
@@ -96,15 +85,8 @@ function parseSpec(spec, service, baseUrl) {
|
|
|
96
85
|
});
|
|
97
86
|
}
|
|
98
87
|
}
|
|
99
|
-
return endpoints;
|
|
88
|
+
return disambiguate(endpoints);
|
|
100
89
|
}
|
|
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
90
|
export async function fetchSpecFromUrl(service, baseUrl) {
|
|
109
91
|
const swaggerUrl = `${baseUrl}/swagger.json`;
|
|
110
92
|
const response = await fetch(swaggerUrl, {
|
|
@@ -116,10 +98,9 @@ export async function fetchSpecFromUrl(service, baseUrl) {
|
|
|
116
98
|
const spec = (await response.json());
|
|
117
99
|
return parseSpec(spec, service, baseUrl);
|
|
118
100
|
}
|
|
119
|
-
export async function fetchSpecs(environment) {
|
|
120
|
-
// Try to load bundled specs first (for speed)
|
|
101
|
+
export async function fetchSpecs(environment, apiHost) {
|
|
121
102
|
try {
|
|
122
|
-
const bundledSpecs = await loadBundledSpecs(environment);
|
|
103
|
+
const bundledSpecs = await loadBundledSpecs(environment, apiHost);
|
|
123
104
|
if (bundledSpecs.length > 0) {
|
|
124
105
|
console.error(`[snokam-mcp] Loaded ${bundledSpecs.length} endpoints from bundled specs (env=${environment})`);
|
|
125
106
|
return bundledSpecs;
|
|
@@ -128,8 +109,6 @@ export async function fetchSpecs(environment) {
|
|
|
128
109
|
catch (error) {
|
|
129
110
|
console.error(`[snokam-mcp] Failed to load bundled specs, falling back to live fetch:`, error);
|
|
130
111
|
}
|
|
131
|
-
// Fallback to live fetching (slower, for development)
|
|
132
|
-
// Discover services from environment to know which APIs to fetch
|
|
133
112
|
const services = await discoverServices(environment);
|
|
134
113
|
if (services.length === 0) {
|
|
135
114
|
console.error("[snokam-mcp] No services discovered. Unable to fetch specs.");
|
|
@@ -137,7 +116,7 @@ export async function fetchSpecs(environment) {
|
|
|
137
116
|
}
|
|
138
117
|
const endpoints = [];
|
|
139
118
|
const results = await Promise.allSettled(services.map(async (service) => {
|
|
140
|
-
const baseUrl = getBaseUrl(service,
|
|
119
|
+
const baseUrl = getBaseUrl(service, apiHost);
|
|
141
120
|
const swaggerUrl = `${baseUrl}/swagger.json`;
|
|
142
121
|
const response = await fetch(swaggerUrl, {
|
|
143
122
|
signal: AbortSignal.timeout(10_000),
|
|
@@ -163,13 +142,12 @@ export async function fetchSpecs(environment) {
|
|
|
163
142
|
console.error(`[snokam-mcp] Loaded ${endpoints.length} endpoints from ${successCount}/${services.length} services (env=${environment})`);
|
|
164
143
|
return endpoints;
|
|
165
144
|
}
|
|
166
|
-
async function loadBundledSpecs(environment) {
|
|
145
|
+
async function loadBundledSpecs(environment, apiHost) {
|
|
167
146
|
const { readFile } = await import("fs/promises");
|
|
168
147
|
const { fileURLToPath } = await import("url");
|
|
169
148
|
const { dirname, join } = await import("path");
|
|
170
149
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
171
150
|
const specsDir = join(__dirname, "..", "specs", environment);
|
|
172
|
-
// Discover which specs are bundled
|
|
173
151
|
const services = await discoverServices(environment);
|
|
174
152
|
const endpoints = [];
|
|
175
153
|
for (const service of services) {
|
|
@@ -177,11 +155,10 @@ async function loadBundledSpecs(environment) {
|
|
|
177
155
|
const specPath = join(specsDir, `${service}.json`);
|
|
178
156
|
const specData = await readFile(specPath, "utf-8");
|
|
179
157
|
const spec = JSON.parse(specData);
|
|
180
|
-
const baseUrl = getBaseUrl(service,
|
|
158
|
+
const baseUrl = getBaseUrl(service, apiHost);
|
|
181
159
|
endpoints.push(...parseSpec(spec, service, baseUrl));
|
|
182
160
|
}
|
|
183
|
-
catch
|
|
184
|
-
// Spec file doesn't exist or is invalid, skip
|
|
161
|
+
catch {
|
|
185
162
|
continue;
|
|
186
163
|
}
|
|
187
164
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface Requirement {
|
|
2
|
+
name: string;
|
|
3
|
+
ok: boolean;
|
|
4
|
+
detail: string;
|
|
5
|
+
fix?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function checkRequirements(): Requirement[];
|
|
8
|
+
export declare function unmetRequirements(): Requirement[];
|
|
9
|
+
export declare function reportRequirements(): Requirement[];
|
|
10
|
+
export declare function blockedReason(): string | null;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { activeApiHost, apiHostOverride, currentEnvironment } from "./state.js";
|
|
2
|
+
import { SWITCH_DOMAIN_TOOL_NAME, SWITCH_TOOL_NAME, VALID_ENVIRONMENTS, } from "./builtin-tools.js";
|
|
3
|
+
const BUSINESS_HOST = /^[a-z0-9-]+\.(test\.)?snosky\.no$/;
|
|
4
|
+
function hostRequirement() {
|
|
5
|
+
const host = activeApiHost();
|
|
6
|
+
const explicit = process.env.SNOKAM_API_HOST?.trim();
|
|
7
|
+
const business = process.env.SNOKAM_BUSINESS?.trim();
|
|
8
|
+
if (apiHostOverride) {
|
|
9
|
+
return {
|
|
10
|
+
name: "api host",
|
|
11
|
+
ok: true,
|
|
12
|
+
detail: `${apiHostOverride} (set with ${SWITCH_DOMAIN_TOOL_NAME})`,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
if (explicit) {
|
|
16
|
+
return { name: "api host", ok: true, detail: `${host} (SNOKAM_API_HOST)` };
|
|
17
|
+
}
|
|
18
|
+
if (business) {
|
|
19
|
+
return {
|
|
20
|
+
name: "api host",
|
|
21
|
+
ok: BUSINESS_HOST.test(host),
|
|
22
|
+
detail: `${host} (SNOKAM_BUSINESS=${business})`,
|
|
23
|
+
fix: BUSINESS_HOST.test(host)
|
|
24
|
+
? undefined
|
|
25
|
+
: `"${business}" does not produce a business host. Call ${SWITCH_DOMAIN_TOOL_NAME} with the host itself, e.g. acme.snosky.no.`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
name: "api host",
|
|
30
|
+
ok: false,
|
|
31
|
+
detail: `${host} — no business chosen`,
|
|
32
|
+
fix: `${host} is the marketing site, not a business API host: requests reach the frontend and come back as its 404 page, which is indistinguishable from a missing document. Call ${SWITCH_DOMAIN_TOOL_NAME} with the business host (e.g. snokam.snosky.no), or start with SNOKAM_BUSINESS=snokam.`,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function environmentRequirement() {
|
|
36
|
+
const ok = VALID_ENVIRONMENTS.includes(currentEnvironment);
|
|
37
|
+
return {
|
|
38
|
+
name: "environment",
|
|
39
|
+
ok,
|
|
40
|
+
detail: currentEnvironment,
|
|
41
|
+
fix: ok
|
|
42
|
+
? undefined
|
|
43
|
+
: `Expected one of ${VALID_ENVIRONMENTS.join(", ")}. Call ${SWITCH_TOOL_NAME}, or set SNOKAM_ENVIRONMENT.`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function credentialsRequirement() {
|
|
47
|
+
if (process.env.SNOKAM_ACCESS_TOKEN) {
|
|
48
|
+
return { name: "credentials", ok: true, detail: "SNOKAM_ACCESS_TOKEN" };
|
|
49
|
+
}
|
|
50
|
+
if (process.env.SNOKAM_ACCESS_TOKEN_FILE) {
|
|
51
|
+
return {
|
|
52
|
+
name: "credentials",
|
|
53
|
+
ok: true,
|
|
54
|
+
detail: "SNOKAM_ACCESS_TOKEN_FILE",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
name: "credentials",
|
|
59
|
+
ok: true,
|
|
60
|
+
detail: "Azure credentials (az login). Calls needing a token fail with 401 if none can be minted.",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function checkRequirements() {
|
|
64
|
+
return [
|
|
65
|
+
hostRequirement(),
|
|
66
|
+
environmentRequirement(),
|
|
67
|
+
credentialsRequirement(),
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
export function unmetRequirements() {
|
|
71
|
+
return checkRequirements().filter((requirement) => !requirement.ok);
|
|
72
|
+
}
|
|
73
|
+
export function reportRequirements() {
|
|
74
|
+
const requirements = checkRequirements();
|
|
75
|
+
for (const requirement of requirements) {
|
|
76
|
+
console.error(`[snokam-mcp] ${requirement.ok ? "✔" : "✖"} ${requirement.name}: ${requirement.detail}`);
|
|
77
|
+
if (requirement.fix)
|
|
78
|
+
console.error(`[snokam-mcp] ${requirement.fix}`);
|
|
79
|
+
}
|
|
80
|
+
return requirements;
|
|
81
|
+
}
|
|
82
|
+
export function blockedReason() {
|
|
83
|
+
const unmet = unmetRequirements();
|
|
84
|
+
if (unmet.length === 0)
|
|
85
|
+
return null;
|
|
86
|
+
return [
|
|
87
|
+
"This call was not sent, because the server is not in a state where it could succeed:",
|
|
88
|
+
...unmet.map((r) => ` ✖ ${r.name}: ${r.detail}\n ${r.fix ?? ""}`.trimEnd()),
|
|
89
|
+
"",
|
|
90
|
+
"Fix the above, then call this tool again. Nothing else has changed.",
|
|
91
|
+
].join("\n");
|
|
92
|
+
}
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
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 settled(): Promise<void>;
|
|
11
|
+
export declare function loadEndpoints(environment: string): Promise<void>;
|
|
12
|
+
export declare function normalizeDomain(raw: string): string;
|
|
13
|
+
export declare function rebuildEndpointsByTool(): void;
|
|
14
|
+
export declare function replaceServiceEndpoints(service: string, freshEndpoints: ApiEndpoint[]): void;
|