@snokam/mcp-api 0.130.0 → 0.131.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.d.ts +0 -14
- package/dist/auth.js +1 -17
- 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 -365
- package/dist/openapi-loader.d.ts +0 -15
- package/dist/openapi-loader.js +1 -39
- 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 +1 -1
package/dist/auth.d.ts
CHANGED
|
@@ -1,15 +1 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Token acquisition for Snokam backend APIs.
|
|
3
|
-
*
|
|
4
|
-
* Modes (auto-detected, in order):
|
|
5
|
-
*
|
|
6
|
-
* 1. **SNOKAM_ACCESS_TOKEN_FILE:** path to a bearer the host refreshes (every
|
|
7
|
-
* ~30 min via the VM managed identity). Read fresh per call so a rotated
|
|
8
|
-
* token is picked up without restarting. Preferred for egress-isolated
|
|
9
|
-
* containers that can't reach IMDS.
|
|
10
|
-
* 2. **SNOKAM_ACCESS_TOKEN:** a pre-minted bearer injected directly.
|
|
11
|
-
* 3. **DefaultAzureCredential:** Azure CLI (local dev) or managed identity (Azure).
|
|
12
|
-
*
|
|
13
|
-
* Endpoints without a scope get no Authorization header.
|
|
14
|
-
*/
|
|
15
1
|
export declare function getAccessToken(scope: string | null): Promise<string | null>;
|
package/dist/auth.js
CHANGED
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Token acquisition for Snokam backend APIs.
|
|
3
|
-
*
|
|
4
|
-
* Modes (auto-detected, in order):
|
|
5
|
-
*
|
|
6
|
-
* 1. **SNOKAM_ACCESS_TOKEN_FILE:** path to a bearer the host refreshes (every
|
|
7
|
-
* ~30 min via the VM managed identity). Read fresh per call so a rotated
|
|
8
|
-
* token is picked up without restarting. Preferred for egress-isolated
|
|
9
|
-
* containers that can't reach IMDS.
|
|
10
|
-
* 2. **SNOKAM_ACCESS_TOKEN:** a pre-minted bearer injected directly.
|
|
11
|
-
* 3. **DefaultAzureCredential:** Azure CLI (local dev) or managed identity (Azure).
|
|
12
|
-
*
|
|
13
|
-
* Endpoints without a scope get no Authorization header.
|
|
14
|
-
*/
|
|
15
1
|
import { readFileSync } from "fs";
|
|
16
2
|
import { DefaultAzureCredential } from "@azure/identity";
|
|
17
3
|
let defaultCredential = null;
|
|
@@ -25,9 +11,7 @@ export async function getAccessToken(scope) {
|
|
|
25
11
|
if (fromFile)
|
|
26
12
|
return fromFile;
|
|
27
13
|
}
|
|
28
|
-
catch {
|
|
29
|
-
// fall through to the other modes
|
|
30
|
-
}
|
|
14
|
+
catch { }
|
|
31
15
|
}
|
|
32
16
|
const preMinted = process.env.SNOKAM_ACCESS_TOKEN;
|
|
33
17
|
if (preMinted)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
export declare const SWITCH_TOOL_NAME = "SwitchEnvironment";
|
|
4
|
+
export declare const VALID_ENVIRONMENTS: string[];
|
|
5
|
+
export declare const switchToolDef: {
|
|
6
|
+
name: string;
|
|
7
|
+
description: string;
|
|
8
|
+
inputSchema: {
|
|
9
|
+
type: "object";
|
|
10
|
+
properties: {
|
|
11
|
+
environment: {
|
|
12
|
+
type: string;
|
|
13
|
+
enum: string[];
|
|
14
|
+
description: string;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
required: string[];
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
export declare const SET_URL_TOOL_NAME = "SetServiceUrl";
|
|
21
|
+
export declare const RESET_URL_TOOL_NAME = "ResetServiceUrl";
|
|
22
|
+
export declare const setUrlToolDef: {
|
|
23
|
+
name: string;
|
|
24
|
+
description: string;
|
|
25
|
+
inputSchema: {
|
|
26
|
+
type: "object";
|
|
27
|
+
properties: {
|
|
28
|
+
service: {
|
|
29
|
+
type: string;
|
|
30
|
+
description: string;
|
|
31
|
+
};
|
|
32
|
+
url: {
|
|
33
|
+
type: string;
|
|
34
|
+
description: string;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
required: string[];
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
export declare const resetUrlToolDef: {
|
|
41
|
+
name: string;
|
|
42
|
+
description: string;
|
|
43
|
+
inputSchema: {
|
|
44
|
+
type: "object";
|
|
45
|
+
properties: {
|
|
46
|
+
service: {
|
|
47
|
+
type: string;
|
|
48
|
+
description: string;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
export declare const SWITCH_DOMAIN_TOOL_NAME = "SwitchDomain";
|
|
54
|
+
export declare const switchDomainToolDef: {
|
|
55
|
+
name: string;
|
|
56
|
+
description: string;
|
|
57
|
+
inputSchema: {
|
|
58
|
+
type: "object";
|
|
59
|
+
properties: {
|
|
60
|
+
domain: {
|
|
61
|
+
type: string;
|
|
62
|
+
description: string;
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
required: string[];
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
export declare const BUILTIN_TOOL_DEFS: ({
|
|
69
|
+
name: string;
|
|
70
|
+
description: string;
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: "object";
|
|
73
|
+
properties: {
|
|
74
|
+
environment: {
|
|
75
|
+
type: string;
|
|
76
|
+
enum: string[];
|
|
77
|
+
description: string;
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
required: string[];
|
|
81
|
+
};
|
|
82
|
+
} | {
|
|
83
|
+
name: string;
|
|
84
|
+
description: string;
|
|
85
|
+
inputSchema: {
|
|
86
|
+
type: "object";
|
|
87
|
+
properties: {
|
|
88
|
+
service: {
|
|
89
|
+
type: string;
|
|
90
|
+
description: string;
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
} | {
|
|
95
|
+
name: string;
|
|
96
|
+
description: string;
|
|
97
|
+
inputSchema: {
|
|
98
|
+
type: "object";
|
|
99
|
+
properties: {
|
|
100
|
+
domain: {
|
|
101
|
+
type: string;
|
|
102
|
+
description: string;
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
required: string[];
|
|
106
|
+
};
|
|
107
|
+
})[];
|
|
108
|
+
export declare const BUILTIN_TOOL_NAMES: Set<string>;
|
|
109
|
+
export declare function handleBuiltinTool(server: Server, name: string, args: Record<string, unknown>): Promise<CallToolResult>;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { fetchSpecFromUrl } from "./openapi-loader.js";
|
|
2
|
+
import { activeApiHost, apiHostOverride, currentEnvironment, endpoints, loadEndpoints, normalizeDomain, replaceServiceEndpoints, serviceUrlOverrides, setApiHostOverride, } from "./state.js";
|
|
3
|
+
export const SWITCH_TOOL_NAME = "SwitchEnvironment";
|
|
4
|
+
export const VALID_ENVIRONMENTS = ["production", "test"];
|
|
5
|
+
export const switchToolDef = {
|
|
6
|
+
name: SWITCH_TOOL_NAME,
|
|
7
|
+
description: "Switch the Snokam MCP server between environments (production/test). Reloads all API specs for the new environment.",
|
|
8
|
+
inputSchema: {
|
|
9
|
+
type: "object",
|
|
10
|
+
properties: {
|
|
11
|
+
environment: {
|
|
12
|
+
type: "string",
|
|
13
|
+
enum: VALID_ENVIRONMENTS,
|
|
14
|
+
description: "The environment to switch to",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
required: ["environment"],
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
export const SET_URL_TOOL_NAME = "SetServiceUrl";
|
|
21
|
+
export const RESET_URL_TOOL_NAME = "ResetServiceUrl";
|
|
22
|
+
export const setUrlToolDef = {
|
|
23
|
+
name: SET_URL_TOOL_NAME,
|
|
24
|
+
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.",
|
|
25
|
+
inputSchema: {
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: {
|
|
28
|
+
service: {
|
|
29
|
+
type: "string",
|
|
30
|
+
description: "The service name (e.g. employees, notifications, events)",
|
|
31
|
+
},
|
|
32
|
+
url: {
|
|
33
|
+
type: "string",
|
|
34
|
+
description: "The base URL to use (e.g. http://localhost:7071)",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
required: ["service", "url"],
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
export const resetUrlToolDef = {
|
|
41
|
+
name: RESET_URL_TOOL_NAME,
|
|
42
|
+
description: "Reset a service's base URL back to the environment default. Call without arguments to reset all overrides.",
|
|
43
|
+
inputSchema: {
|
|
44
|
+
type: "object",
|
|
45
|
+
properties: {
|
|
46
|
+
service: {
|
|
47
|
+
type: "string",
|
|
48
|
+
description: "The service name to reset. Omit to reset all overrides.",
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
export const SWITCH_DOMAIN_TOOL_NAME = "SwitchDomain";
|
|
54
|
+
export const switchDomainToolDef = {
|
|
55
|
+
name: SWITCH_DOMAIN_TOOL_NAME,
|
|
56
|
+
description: "Point all Snokam API calls at a specific business's API host, so the same MCP can serve any tenant. Pass the business's API host (custom, e.g. acme.no, or its snosky fallback, e.g. acme.snosky.no); calls then go to {host}/api/{service}. Pass an empty string to reset to the environment default.",
|
|
57
|
+
inputSchema: {
|
|
58
|
+
type: "object",
|
|
59
|
+
properties: {
|
|
60
|
+
domain: {
|
|
61
|
+
type: "string",
|
|
62
|
+
description: "The business's API host (e.g. acme.snosky.no). Empty string resets to the environment default.",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
required: ["domain"],
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
export const BUILTIN_TOOL_DEFS = [
|
|
69
|
+
switchToolDef,
|
|
70
|
+
switchDomainToolDef,
|
|
71
|
+
setUrlToolDef,
|
|
72
|
+
resetUrlToolDef,
|
|
73
|
+
];
|
|
74
|
+
export const BUILTIN_TOOL_NAMES = new Set(BUILTIN_TOOL_DEFS.map((def) => def.name));
|
|
75
|
+
export async function handleBuiltinTool(server, name, args) {
|
|
76
|
+
if (name === SWITCH_TOOL_NAME) {
|
|
77
|
+
return handleSwitchEnvironment(server, args);
|
|
78
|
+
}
|
|
79
|
+
if (name === SWITCH_DOMAIN_TOOL_NAME) {
|
|
80
|
+
return handleSwitchDomain(server, args);
|
|
81
|
+
}
|
|
82
|
+
if (name === SET_URL_TOOL_NAME) {
|
|
83
|
+
return handleSetServiceUrl(server, args);
|
|
84
|
+
}
|
|
85
|
+
return handleResetServiceUrl(args);
|
|
86
|
+
}
|
|
87
|
+
async function handleSwitchEnvironment(server, args) {
|
|
88
|
+
const env = String(args.environment ?? "");
|
|
89
|
+
if (!VALID_ENVIRONMENTS.includes(env)) {
|
|
90
|
+
return {
|
|
91
|
+
content: [
|
|
92
|
+
{
|
|
93
|
+
type: "text",
|
|
94
|
+
text: `Invalid environment: ${env}. Must be one of: ${VALID_ENVIRONMENTS.join(", ")}`,
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
isError: true,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (env === currentEnvironment) {
|
|
101
|
+
return {
|
|
102
|
+
content: [
|
|
103
|
+
{
|
|
104
|
+
type: "text",
|
|
105
|
+
text: `Already connected to ${env} (${endpoints.length} endpoints)`,
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
await loadEndpoints(env);
|
|
111
|
+
await server.sendToolListChanged();
|
|
112
|
+
return {
|
|
113
|
+
content: [
|
|
114
|
+
{
|
|
115
|
+
type: "text",
|
|
116
|
+
text: `Switched to ${env} environment. Loaded ${endpoints.length} endpoints.`,
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
async function handleSwitchDomain(server, args) {
|
|
122
|
+
setApiHostOverride(normalizeDomain(String(args.domain ?? "")) || null);
|
|
123
|
+
await loadEndpoints(currentEnvironment);
|
|
124
|
+
await server.sendToolListChanged();
|
|
125
|
+
return {
|
|
126
|
+
content: [
|
|
127
|
+
{
|
|
128
|
+
type: "text",
|
|
129
|
+
text: apiHostOverride
|
|
130
|
+
? `API calls now target ${apiHostOverride}/api/{service}.`
|
|
131
|
+
: `API host reset to the environment default (${activeApiHost()}).`,
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
async function handleSetServiceUrl(server, args) {
|
|
137
|
+
const service = String(args.service ?? "");
|
|
138
|
+
const url = String(args.url ?? "");
|
|
139
|
+
const isLocal = url.startsWith("http://localhost") || url.startsWith("http://127.0.0.1");
|
|
140
|
+
if (isLocal) {
|
|
141
|
+
try {
|
|
142
|
+
const liveEndpoints = await fetchSpecFromUrl(service, url);
|
|
143
|
+
replaceServiceEndpoints(service, liveEndpoints);
|
|
144
|
+
serviceUrlOverrides.set(service, url);
|
|
145
|
+
await server.sendToolListChanged();
|
|
146
|
+
return {
|
|
147
|
+
content: [
|
|
148
|
+
{
|
|
149
|
+
type: "text",
|
|
150
|
+
text: `Overrode ${service} → ${url}. Loaded ${liveEndpoints.length} endpoints from local swagger.`,
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
console.error(`[snokam-mcp] Failed to fetch swagger from ${url}, falling back to static spec:`, error instanceof Error ? error.message : error);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const serviceEndpoints = endpoints.filter((ep) => ep.service === service);
|
|
160
|
+
if (serviceEndpoints.length === 0) {
|
|
161
|
+
const available = [...new Set(endpoints.map((ep) => ep.service))].sort();
|
|
162
|
+
return {
|
|
163
|
+
content: [
|
|
164
|
+
{
|
|
165
|
+
type: "text",
|
|
166
|
+
text: `Unknown service: ${service}. Available: ${available.join(", ")}`,
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
isError: true,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
serviceUrlOverrides.set(service, url);
|
|
173
|
+
for (const ep of serviceEndpoints) {
|
|
174
|
+
ep.baseUrl = url;
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
content: [
|
|
178
|
+
{
|
|
179
|
+
type: "text",
|
|
180
|
+
text: `Overrode ${service} → ${url} (${serviceEndpoints.length} endpoints).`,
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
async function handleResetServiceUrl(args) {
|
|
186
|
+
const service = args.service ? String(args.service) : undefined;
|
|
187
|
+
if (service) {
|
|
188
|
+
serviceUrlOverrides.delete(service);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
serviceUrlOverrides.clear();
|
|
192
|
+
}
|
|
193
|
+
await loadEndpoints(currentEnvironment);
|
|
194
|
+
const msg = service
|
|
195
|
+
? `Reset ${service} to environment default`
|
|
196
|
+
: `Reset all service URL overrides`;
|
|
197
|
+
return {
|
|
198
|
+
content: [{ type: "text", text: msg }],
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { getAccessToken } from "./auth.js";
|
|
2
|
+
export async function executeCall(endpoint, args) {
|
|
3
|
+
let url = `${endpoint.baseUrl}${endpoint.path}`;
|
|
4
|
+
const queryParams = [];
|
|
5
|
+
for (const param of endpoint.parameters) {
|
|
6
|
+
const value = args[param.name];
|
|
7
|
+
if (value === undefined)
|
|
8
|
+
continue;
|
|
9
|
+
if (param.in === "path") {
|
|
10
|
+
url = url.replace(`{${param.name}}`, encodeURIComponent(String(value)));
|
|
11
|
+
}
|
|
12
|
+
else if (param.in === "query") {
|
|
13
|
+
queryParams.push(`${encodeURIComponent(param.name)}=${encodeURIComponent(String(value))}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
if (queryParams.length > 0) {
|
|
17
|
+
url += `?${queryParams.join("&")}`;
|
|
18
|
+
}
|
|
19
|
+
const headers = {
|
|
20
|
+
Accept: "application/json",
|
|
21
|
+
};
|
|
22
|
+
const token = await getAccessToken(endpoint.scope);
|
|
23
|
+
if (token) {
|
|
24
|
+
headers.Authorization = `Bearer ${token}`;
|
|
25
|
+
}
|
|
26
|
+
let fetchBody;
|
|
27
|
+
if (args.body !== undefined && endpoint.method !== "GET") {
|
|
28
|
+
headers["Content-Type"] = "application/json";
|
|
29
|
+
fetchBody = JSON.stringify(args.body);
|
|
30
|
+
}
|
|
31
|
+
const response = await fetch(url, {
|
|
32
|
+
method: endpoint.method,
|
|
33
|
+
headers,
|
|
34
|
+
body: fetchBody,
|
|
35
|
+
});
|
|
36
|
+
let body;
|
|
37
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
38
|
+
if (contentType.includes("application/json")) {
|
|
39
|
+
body = await response.json();
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
body = await response.text();
|
|
43
|
+
}
|
|
44
|
+
return { status: response.status, body };
|
|
45
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,2 @@
|
|
|
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: a pre-minted SNOKAM_ACCESS_TOKEN when present, else @azure/identity
|
|
7
|
-
* (Azure CLI locally, managed identity in Azure).
|
|
8
|
-
*/
|
|
9
2
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -1,229 +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: a pre-minted SNOKAM_ACCESS_TOKEN when present, else @azure/identity
|
|
7
|
-
* (Azure CLI locally, managed identity in Azure).
|
|
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 apiHostOverride = null;
|
|
19
|
-
let endpoints = [];
|
|
20
|
-
let endpointsByTool = new Map();
|
|
21
|
-
const serviceUrlOverrides = new Map();
|
|
22
|
-
// Backends are reached at {host}/api/{service} on the business's snosky host.
|
|
23
|
-
// SNOKAM_API_HOST sets it explicitly (injected per-business), else it's derived
|
|
24
|
-
// from SNOKAM_BUSINESS + env, and SwitchDomain overrides it at runtime.
|
|
25
|
-
function resolveApiHost(environment) {
|
|
26
|
-
const explicit = process.env.SNOKAM_API_HOST;
|
|
27
|
-
if (explicit)
|
|
28
|
-
return normalizeDomain(explicit);
|
|
29
|
-
const suffix = environment === "test" ? "test.snosky.no" : "snosky.no";
|
|
30
|
-
const business = process.env.SNOKAM_BUSINESS;
|
|
31
|
-
return business ? `${business}.${suffix}` : suffix;
|
|
32
|
-
}
|
|
33
|
-
function activeApiHost() {
|
|
34
|
-
return apiHostOverride ?? resolveApiHost(currentEnvironment);
|
|
35
|
-
}
|
|
36
|
-
async function loadEndpoints(environment) {
|
|
37
|
-
currentEnvironment = environment;
|
|
38
|
-
endpoints = await fetchSpecs(environment, activeApiHost());
|
|
39
|
-
endpointsByTool = new Map();
|
|
40
|
-
for (const ep of endpoints) {
|
|
41
|
-
endpointsByTool.set(ep.toolName, ep);
|
|
42
|
-
}
|
|
43
|
-
// Layer per-service overrides (e.g. localhost) on top; most specific wins.
|
|
44
|
-
applyUrlOverrides();
|
|
45
|
-
}
|
|
46
|
-
function applyUrlOverrides() {
|
|
47
|
-
for (const ep of endpoints) {
|
|
48
|
-
const override = serviceUrlOverrides.get(ep.service);
|
|
49
|
-
if (override) {
|
|
50
|
-
ep.baseUrl = override;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
function normalizeDomain(raw) {
|
|
55
|
-
return raw
|
|
56
|
-
.trim()
|
|
57
|
-
.replace(/^https?:\/\//, "")
|
|
58
|
-
.replace(/\/$/, "");
|
|
59
|
-
}
|
|
60
|
-
// ---------------------------------------------------------------------------
|
|
61
|
-
// Built-in tool: SwitchEnvironment
|
|
62
|
-
// ---------------------------------------------------------------------------
|
|
63
|
-
const SWITCH_TOOL_NAME = "SwitchEnvironment";
|
|
64
|
-
const VALID_ENVIRONMENTS = ["production", "test"];
|
|
65
|
-
const switchToolDef = {
|
|
66
|
-
name: SWITCH_TOOL_NAME,
|
|
67
|
-
description: "Switch the Snokam MCP server between environments (production/test). Reloads all API specs for the new environment.",
|
|
68
|
-
inputSchema: {
|
|
69
|
-
type: "object",
|
|
70
|
-
properties: {
|
|
71
|
-
environment: {
|
|
72
|
-
type: "string",
|
|
73
|
-
enum: VALID_ENVIRONMENTS,
|
|
74
|
-
description: "The environment to switch to",
|
|
75
|
-
},
|
|
76
|
-
},
|
|
77
|
-
required: ["environment"],
|
|
78
|
-
},
|
|
79
|
-
};
|
|
80
|
-
// ---------------------------------------------------------------------------
|
|
81
|
-
// Built-in tool: SetServiceUrl
|
|
82
|
-
// ---------------------------------------------------------------------------
|
|
83
|
-
const SET_URL_TOOL_NAME = "SetServiceUrl";
|
|
84
|
-
const RESET_URL_TOOL_NAME = "ResetServiceUrl";
|
|
85
|
-
const setUrlToolDef = {
|
|
86
|
-
name: SET_URL_TOOL_NAME,
|
|
87
|
-
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.",
|
|
88
|
-
inputSchema: {
|
|
89
|
-
type: "object",
|
|
90
|
-
properties: {
|
|
91
|
-
service: {
|
|
92
|
-
type: "string",
|
|
93
|
-
description: "The service name (e.g. employees, notifications, events)",
|
|
94
|
-
},
|
|
95
|
-
url: {
|
|
96
|
-
type: "string",
|
|
97
|
-
description: "The base URL to use (e.g. http://localhost:7071)",
|
|
98
|
-
},
|
|
99
|
-
},
|
|
100
|
-
required: ["service", "url"],
|
|
101
|
-
},
|
|
102
|
-
};
|
|
103
|
-
const resetUrlToolDef = {
|
|
104
|
-
name: RESET_URL_TOOL_NAME,
|
|
105
|
-
description: "Reset a service's base URL back to the environment default. Call without arguments to reset all overrides.",
|
|
106
|
-
inputSchema: {
|
|
107
|
-
type: "object",
|
|
108
|
-
properties: {
|
|
109
|
-
service: {
|
|
110
|
-
type: "string",
|
|
111
|
-
description: "The service name to reset. Omit to reset all overrides.",
|
|
112
|
-
},
|
|
113
|
-
},
|
|
114
|
-
},
|
|
115
|
-
};
|
|
116
|
-
const SWITCH_DOMAIN_TOOL_NAME = "SwitchDomain";
|
|
117
|
-
const switchDomainToolDef = {
|
|
118
|
-
name: SWITCH_DOMAIN_TOOL_NAME,
|
|
119
|
-
description: "Point all Snokam API calls at a specific business's API host, so the same MCP can serve any tenant. Pass the business's API host (custom, e.g. acme.no, or its snosky fallback, e.g. acme.snosky.no); calls then go to {host}/api/{service}. Pass an empty string to reset to the environment default.",
|
|
120
|
-
inputSchema: {
|
|
121
|
-
type: "object",
|
|
122
|
-
properties: {
|
|
123
|
-
domain: {
|
|
124
|
-
type: "string",
|
|
125
|
-
description: "The business's API host (e.g. acme.snosky.no). Empty string resets to the environment default.",
|
|
126
|
-
},
|
|
127
|
-
},
|
|
128
|
-
required: ["domain"],
|
|
129
|
-
},
|
|
130
|
-
};
|
|
131
|
-
// ---------------------------------------------------------------------------
|
|
132
|
-
// JSON Schema builder for tool inputs
|
|
133
|
-
// ---------------------------------------------------------------------------
|
|
134
|
-
function buildInputSchema(endpoint) {
|
|
135
|
-
const properties = {};
|
|
136
|
-
const required = [];
|
|
137
|
-
for (const param of endpoint.parameters) {
|
|
138
|
-
const prop = {};
|
|
139
|
-
if (param.schema?.type)
|
|
140
|
-
prop.type = param.schema.type;
|
|
141
|
-
if (param.schema?.enum)
|
|
142
|
-
prop.enum = param.schema.enum;
|
|
143
|
-
if (param.schema?.format)
|
|
144
|
-
prop.format = param.schema.format;
|
|
145
|
-
if (param.schema?.items)
|
|
146
|
-
prop.items = param.schema.items;
|
|
147
|
-
if (param.description)
|
|
148
|
-
prop.description = param.description;
|
|
149
|
-
if (!prop.type)
|
|
150
|
-
prop.type = "string";
|
|
151
|
-
properties[param.name] = prop;
|
|
152
|
-
if (param.required)
|
|
153
|
-
required.push(param.name);
|
|
154
|
-
}
|
|
155
|
-
if (endpoint.requestBody) {
|
|
156
|
-
properties.body = {
|
|
157
|
-
type: "object",
|
|
158
|
-
description: endpoint.requestBody.description ?? "Request body",
|
|
159
|
-
};
|
|
160
|
-
// Extract schema from content type if available
|
|
161
|
-
const jsonContent = endpoint.requestBody.content?.["application/json"];
|
|
162
|
-
if (jsonContent?.schema) {
|
|
163
|
-
properties.body = {
|
|
164
|
-
...properties.body,
|
|
165
|
-
...jsonContent.schema,
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
if (endpoint.requestBody.required)
|
|
169
|
-
required.push("body");
|
|
170
|
-
}
|
|
171
|
-
return {
|
|
172
|
-
type: "object",
|
|
173
|
-
properties,
|
|
174
|
-
required: required.length > 0 ? required : undefined,
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
// ---------------------------------------------------------------------------
|
|
178
|
-
// HTTP call execution
|
|
179
|
-
// ---------------------------------------------------------------------------
|
|
180
|
-
async function executeCall(endpoint, args) {
|
|
181
|
-
let url = `${endpoint.baseUrl}${endpoint.path}`;
|
|
182
|
-
const queryParams = [];
|
|
183
|
-
for (const param of endpoint.parameters) {
|
|
184
|
-
const value = args[param.name];
|
|
185
|
-
if (value === undefined)
|
|
186
|
-
continue;
|
|
187
|
-
if (param.in === "path") {
|
|
188
|
-
url = url.replace(`{${param.name}}`, encodeURIComponent(String(value)));
|
|
189
|
-
}
|
|
190
|
-
else if (param.in === "query") {
|
|
191
|
-
queryParams.push(`${encodeURIComponent(param.name)}=${encodeURIComponent(String(value))}`);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
if (queryParams.length > 0) {
|
|
195
|
-
url += `?${queryParams.join("&")}`;
|
|
196
|
-
}
|
|
197
|
-
const headers = {
|
|
198
|
-
Accept: "application/json",
|
|
199
|
-
};
|
|
200
|
-
const token = await getAccessToken(endpoint.scope);
|
|
201
|
-
if (token) {
|
|
202
|
-
headers.Authorization = `Bearer ${token}`;
|
|
203
|
-
}
|
|
204
|
-
let fetchBody;
|
|
205
|
-
if (args.body !== undefined && endpoint.method !== "GET") {
|
|
206
|
-
headers["Content-Type"] = "application/json";
|
|
207
|
-
fetchBody = JSON.stringify(args.body);
|
|
208
|
-
}
|
|
209
|
-
const response = await fetch(url, {
|
|
210
|
-
method: endpoint.method,
|
|
211
|
-
headers,
|
|
212
|
-
body: fetchBody,
|
|
213
|
-
});
|
|
214
|
-
let body;
|
|
215
|
-
const contentType = response.headers.get("content-type") ?? "";
|
|
216
|
-
if (contentType.includes("application/json")) {
|
|
217
|
-
body = await response.json();
|
|
218
|
-
}
|
|
219
|
-
else {
|
|
220
|
-
body = await response.text();
|
|
221
|
-
}
|
|
222
|
-
return { status: response.status, body };
|
|
223
|
-
}
|
|
224
|
-
// ---------------------------------------------------------------------------
|
|
225
|
-
// Server setup
|
|
226
|
-
// ---------------------------------------------------------------------------
|
|
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";
|
|
227
9
|
async function main() {
|
|
228
10
|
await loadEndpoints(currentEnvironment);
|
|
229
11
|
if (endpoints.length === 0) {
|
|
@@ -238,7 +20,6 @@ async function main() {
|
|
|
238
20
|
resources: {},
|
|
239
21
|
},
|
|
240
22
|
});
|
|
241
|
-
// List resources
|
|
242
23
|
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
243
24
|
resources: [
|
|
244
25
|
{
|
|
@@ -249,11 +30,9 @@ async function main() {
|
|
|
249
30
|
},
|
|
250
31
|
],
|
|
251
32
|
}));
|
|
252
|
-
// Read resource
|
|
253
33
|
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
254
34
|
const { uri } = request.params;
|
|
255
35
|
if (uri === "snokam://about") {
|
|
256
|
-
// Group endpoints by service with their descriptions
|
|
257
36
|
const serviceMap = new Map();
|
|
258
37
|
for (const ep of endpoints) {
|
|
259
38
|
const existing = serviceMap.get(ep.service);
|
|
@@ -325,13 +104,9 @@ Controls: Sonos speakers, lights, YouTube queue
|
|
|
325
104
|
isError: true,
|
|
326
105
|
};
|
|
327
106
|
});
|
|
328
|
-
// List tools
|
|
329
107
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
330
108
|
tools: [
|
|
331
|
-
|
|
332
|
-
switchDomainToolDef,
|
|
333
|
-
setUrlToolDef,
|
|
334
|
-
resetUrlToolDef,
|
|
109
|
+
...BUILTIN_TOOL_DEFS,
|
|
335
110
|
...endpoints.map((ep) => ({
|
|
336
111
|
name: ep.toolName,
|
|
337
112
|
description: ep.description || ep.summary || `${ep.method} ${ep.path}`,
|
|
@@ -339,143 +114,10 @@ Controls: Sonos speakers, lights, YouTube queue
|
|
|
339
114
|
})),
|
|
340
115
|
],
|
|
341
116
|
}));
|
|
342
|
-
// Call tool
|
|
343
117
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
344
118
|
const { name, arguments: args = {} } = request.params;
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
const env = String(args.environment ?? "");
|
|
348
|
-
if (!VALID_ENVIRONMENTS.includes(env)) {
|
|
349
|
-
return {
|
|
350
|
-
content: [
|
|
351
|
-
{
|
|
352
|
-
type: "text",
|
|
353
|
-
text: `Invalid environment: ${env}. Must be one of: ${VALID_ENVIRONMENTS.join(", ")}`,
|
|
354
|
-
},
|
|
355
|
-
],
|
|
356
|
-
isError: true,
|
|
357
|
-
};
|
|
358
|
-
}
|
|
359
|
-
if (env === currentEnvironment) {
|
|
360
|
-
return {
|
|
361
|
-
content: [
|
|
362
|
-
{
|
|
363
|
-
type: "text",
|
|
364
|
-
text: `Already connected to ${env} (${endpoints.length} endpoints)`,
|
|
365
|
-
},
|
|
366
|
-
],
|
|
367
|
-
};
|
|
368
|
-
}
|
|
369
|
-
await loadEndpoints(env);
|
|
370
|
-
// Notify client that the tool list has changed
|
|
371
|
-
await server.sendToolListChanged();
|
|
372
|
-
return {
|
|
373
|
-
content: [
|
|
374
|
-
{
|
|
375
|
-
type: "text",
|
|
376
|
-
text: `Switched to ${env} environment. Loaded ${endpoints.length} endpoints.`,
|
|
377
|
-
},
|
|
378
|
-
],
|
|
379
|
-
};
|
|
380
|
-
}
|
|
381
|
-
// Handle SwitchDomain
|
|
382
|
-
if (name === SWITCH_DOMAIN_TOOL_NAME) {
|
|
383
|
-
apiHostOverride = normalizeDomain(String(args.domain ?? "")) || null;
|
|
384
|
-
await loadEndpoints(currentEnvironment);
|
|
385
|
-
await server.sendToolListChanged();
|
|
386
|
-
return {
|
|
387
|
-
content: [
|
|
388
|
-
{
|
|
389
|
-
type: "text",
|
|
390
|
-
text: apiHostOverride
|
|
391
|
-
? `API calls now target ${apiHostOverride}/api/{service}.`
|
|
392
|
-
: `API host reset to the environment default (${activeApiHost()}).`,
|
|
393
|
-
},
|
|
394
|
-
],
|
|
395
|
-
};
|
|
396
|
-
}
|
|
397
|
-
// Handle SetServiceUrl
|
|
398
|
-
if (name === SET_URL_TOOL_NAME) {
|
|
399
|
-
const service = String(args.service ?? "");
|
|
400
|
-
const url = String(args.url ?? "");
|
|
401
|
-
const isLocal = url.startsWith("http://localhost") ||
|
|
402
|
-
url.startsWith("http://127.0.0.1");
|
|
403
|
-
// For localhost URLs, fetch the live swagger spec from the local function
|
|
404
|
-
// to pick up any new/changed endpoints during development
|
|
405
|
-
if (isLocal) {
|
|
406
|
-
try {
|
|
407
|
-
const liveEndpoints = await fetchSpecFromUrl(service, url);
|
|
408
|
-
// Remove old endpoints for this service
|
|
409
|
-
endpoints = endpoints.filter((ep) => ep.service !== service);
|
|
410
|
-
// Add the fresh ones
|
|
411
|
-
endpoints.push(...liveEndpoints);
|
|
412
|
-
// Rebuild lookup map
|
|
413
|
-
endpointsByTool = new Map();
|
|
414
|
-
for (const ep of endpoints) {
|
|
415
|
-
endpointsByTool.set(ep.toolName, ep);
|
|
416
|
-
}
|
|
417
|
-
serviceUrlOverrides.set(service, url);
|
|
418
|
-
await server.sendToolListChanged();
|
|
419
|
-
return {
|
|
420
|
-
content: [
|
|
421
|
-
{
|
|
422
|
-
type: "text",
|
|
423
|
-
text: `Overrode ${service} → ${url}. Loaded ${liveEndpoints.length} endpoints from local swagger.`,
|
|
424
|
-
},
|
|
425
|
-
],
|
|
426
|
-
};
|
|
427
|
-
}
|
|
428
|
-
catch (error) {
|
|
429
|
-
// Fall through to static override if swagger fetch fails
|
|
430
|
-
console.error(`[snokam-mcp] Failed to fetch swagger from ${url}, falling back to static spec:`, error instanceof Error ? error.message : error);
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
// Static override: just change the base URL on existing endpoints
|
|
434
|
-
const serviceEndpoints = endpoints.filter((ep) => ep.service === service);
|
|
435
|
-
if (serviceEndpoints.length === 0) {
|
|
436
|
-
const available = [
|
|
437
|
-
...new Set(endpoints.map((ep) => ep.service)),
|
|
438
|
-
].sort();
|
|
439
|
-
return {
|
|
440
|
-
content: [
|
|
441
|
-
{
|
|
442
|
-
type: "text",
|
|
443
|
-
text: `Unknown service: ${service}. Available: ${available.join(", ")}`,
|
|
444
|
-
},
|
|
445
|
-
],
|
|
446
|
-
isError: true,
|
|
447
|
-
};
|
|
448
|
-
}
|
|
449
|
-
serviceUrlOverrides.set(service, url);
|
|
450
|
-
for (const ep of serviceEndpoints) {
|
|
451
|
-
ep.baseUrl = url;
|
|
452
|
-
}
|
|
453
|
-
return {
|
|
454
|
-
content: [
|
|
455
|
-
{
|
|
456
|
-
type: "text",
|
|
457
|
-
text: `Overrode ${service} → ${url} (${serviceEndpoints.length} endpoints).`,
|
|
458
|
-
},
|
|
459
|
-
],
|
|
460
|
-
};
|
|
461
|
-
}
|
|
462
|
-
// Handle ResetServiceUrl
|
|
463
|
-
if (name === RESET_URL_TOOL_NAME) {
|
|
464
|
-
const service = args.service ? String(args.service) : undefined;
|
|
465
|
-
if (service) {
|
|
466
|
-
serviceUrlOverrides.delete(service);
|
|
467
|
-
}
|
|
468
|
-
else {
|
|
469
|
-
serviceUrlOverrides.clear();
|
|
470
|
-
}
|
|
471
|
-
// Reload to restore original URLs
|
|
472
|
-
await loadEndpoints(currentEnvironment);
|
|
473
|
-
const msg = service
|
|
474
|
-
? `Reset ${service} to environment default`
|
|
475
|
-
: `Reset all service URL overrides`;
|
|
476
|
-
return {
|
|
477
|
-
content: [{ type: "text", text: msg }],
|
|
478
|
-
};
|
|
119
|
+
if (BUILTIN_TOOL_NAMES.has(name)) {
|
|
120
|
+
return handleBuiltinTool(server, name, args);
|
|
479
121
|
}
|
|
480
122
|
const endpoint = endpointsByTool.get(name);
|
|
481
123
|
if (!endpoint) {
|
package/dist/openapi-loader.d.ts
CHANGED
|
@@ -1,13 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fetch OpenAPI specs from live Snokam APIs and produce tool definitions.
|
|
3
|
-
*
|
|
4
|
-
* Backends are reached per-business at `{business}.snosky.no/api/{service}`
|
|
5
|
-
* (prod) or `{business}.test.snosky.no/api/{service}` (test) — the uniform
|
|
6
|
-
* snosky routing that works for every tenant, no per-customer hostname. The
|
|
7
|
-
* business API host is injected (SNOKAM_API_HOST) and can be retargeted at
|
|
8
|
-
* runtime via the SwitchDomain tool. The loader extracts endpoints,
|
|
9
|
-
* parameters, and OAuth scopes for MCP tool metadata.
|
|
10
|
-
*/
|
|
11
1
|
export interface ApiEndpoint {
|
|
12
2
|
service: string;
|
|
13
3
|
serviceDescription: string;
|
|
@@ -20,7 +10,6 @@ export interface ApiEndpoint {
|
|
|
20
10
|
description: string;
|
|
21
11
|
parameters: OpenApiParameter[];
|
|
22
12
|
requestBody: OpenApiRequestBody | null;
|
|
23
|
-
/** OAuth2 scope in `.default` format for OBO exchange, or null for public endpoints. */
|
|
24
13
|
scope: string | null;
|
|
25
14
|
}
|
|
26
15
|
interface OpenApiParameter {
|
|
@@ -44,10 +33,6 @@ interface OpenApiRequestBody {
|
|
|
44
33
|
schema?: Record<string, unknown>;
|
|
45
34
|
}>;
|
|
46
35
|
}
|
|
47
|
-
/**
|
|
48
|
-
* Fetch a swagger spec from a specific URL (e.g. a locally running function)
|
|
49
|
-
* and return parsed endpoints for that service.
|
|
50
|
-
*/
|
|
51
36
|
export declare function fetchSpecFromUrl(service: string, baseUrl: string): Promise<ApiEndpoint[]>;
|
|
52
37
|
export declare function fetchSpecs(environment: string, apiHost: string): Promise<ApiEndpoint[]>;
|
|
53
38
|
export {};
|
package/dist/openapi-loader.js
CHANGED
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fetch OpenAPI specs from live Snokam APIs and produce tool definitions.
|
|
3
|
-
*
|
|
4
|
-
* Backends are reached per-business at `{business}.snosky.no/api/{service}`
|
|
5
|
-
* (prod) or `{business}.test.snosky.no/api/{service}` (test) — the uniform
|
|
6
|
-
* snosky routing that works for every tenant, no per-customer hostname. The
|
|
7
|
-
* business API host is injected (SNOKAM_API_HOST) and can be retargeted at
|
|
8
|
-
* runtime via the SwitchDomain tool. The loader extracts endpoints,
|
|
9
|
-
* parameters, and OAuth scopes for MCP tool metadata.
|
|
10
|
-
*/
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
// Service discovery - reads from bundled specs directory at runtime
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
1
|
async function discoverServices(environment) {
|
|
15
2
|
try {
|
|
16
3
|
const { readdir } = await import("fs/promises");
|
|
@@ -25,19 +12,12 @@ async function discoverServices(environment) {
|
|
|
25
12
|
.sort();
|
|
26
13
|
}
|
|
27
14
|
catch {
|
|
28
|
-
// No bundled specs found - return empty array
|
|
29
15
|
return [];
|
|
30
16
|
}
|
|
31
17
|
}
|
|
32
|
-
// ---------------------------------------------------------------------------
|
|
33
|
-
// Environment-aware URL resolution
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
18
|
function getBaseUrl(service, apiHost) {
|
|
36
19
|
return `https://${apiHost}/api/${service}`;
|
|
37
20
|
}
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
// Scope extraction
|
|
40
|
-
// ---------------------------------------------------------------------------
|
|
41
21
|
function extractScope(operation) {
|
|
42
22
|
for (const secReq of operation.security ?? []) {
|
|
43
23
|
for (const scopes of Object.values(secReq)) {
|
|
@@ -53,15 +33,9 @@ function extractScope(operation) {
|
|
|
53
33
|
}
|
|
54
34
|
return null;
|
|
55
35
|
}
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
// Tool naming
|
|
58
|
-
// ---------------------------------------------------------------------------
|
|
59
36
|
function makeToolName(service, operationId) {
|
|
60
37
|
return `${service}__${operationId}`;
|
|
61
38
|
}
|
|
62
|
-
// ---------------------------------------------------------------------------
|
|
63
|
-
// Spec parsing
|
|
64
|
-
// ---------------------------------------------------------------------------
|
|
65
39
|
function parseSpec(spec, service, baseUrl) {
|
|
66
40
|
const endpoints = [];
|
|
67
41
|
const paths = spec.paths;
|
|
@@ -95,13 +69,6 @@ function parseSpec(spec, service, baseUrl) {
|
|
|
95
69
|
}
|
|
96
70
|
return endpoints;
|
|
97
71
|
}
|
|
98
|
-
// ---------------------------------------------------------------------------
|
|
99
|
-
// Public API
|
|
100
|
-
// ---------------------------------------------------------------------------
|
|
101
|
-
/**
|
|
102
|
-
* Fetch a swagger spec from a specific URL (e.g. a locally running function)
|
|
103
|
-
* and return parsed endpoints for that service.
|
|
104
|
-
*/
|
|
105
72
|
export async function fetchSpecFromUrl(service, baseUrl) {
|
|
106
73
|
const swaggerUrl = `${baseUrl}/swagger.json`;
|
|
107
74
|
const response = await fetch(swaggerUrl, {
|
|
@@ -114,7 +81,6 @@ export async function fetchSpecFromUrl(service, baseUrl) {
|
|
|
114
81
|
return parseSpec(spec, service, baseUrl);
|
|
115
82
|
}
|
|
116
83
|
export async function fetchSpecs(environment, apiHost) {
|
|
117
|
-
// Try to load bundled specs first (for speed)
|
|
118
84
|
try {
|
|
119
85
|
const bundledSpecs = await loadBundledSpecs(environment, apiHost);
|
|
120
86
|
if (bundledSpecs.length > 0) {
|
|
@@ -125,8 +91,6 @@ export async function fetchSpecs(environment, apiHost) {
|
|
|
125
91
|
catch (error) {
|
|
126
92
|
console.error(`[snokam-mcp] Failed to load bundled specs, falling back to live fetch:`, error);
|
|
127
93
|
}
|
|
128
|
-
// Fallback to live fetching (slower, for development)
|
|
129
|
-
// Discover services from environment to know which APIs to fetch
|
|
130
94
|
const services = await discoverServices(environment);
|
|
131
95
|
if (services.length === 0) {
|
|
132
96
|
console.error("[snokam-mcp] No services discovered. Unable to fetch specs.");
|
|
@@ -166,7 +130,6 @@ async function loadBundledSpecs(environment, apiHost) {
|
|
|
166
130
|
const { dirname, join } = await import("path");
|
|
167
131
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
168
132
|
const specsDir = join(__dirname, "..", "specs", environment);
|
|
169
|
-
// Discover which specs are bundled
|
|
170
133
|
const services = await discoverServices(environment);
|
|
171
134
|
const endpoints = [];
|
|
172
135
|
for (const service of services) {
|
|
@@ -177,8 +140,7 @@ async function loadBundledSpecs(environment, apiHost) {
|
|
|
177
140
|
const baseUrl = getBaseUrl(service, apiHost);
|
|
178
141
|
endpoints.push(...parseSpec(spec, service, baseUrl));
|
|
179
142
|
}
|
|
180
|
-
catch
|
|
181
|
-
// Spec file doesn't exist or is invalid, skip
|
|
143
|
+
catch {
|
|
182
144
|
continue;
|
|
183
145
|
}
|
|
184
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
|
+
}
|