@snokam/mcp-api 0.130.0 → 0.131.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 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,5 @@
1
+ import { type ApiEndpoint } from "./openapi-loader.js";
2
+ export declare function executeCall(endpoint: ApiEndpoint, args: Record<string, unknown>): Promise<{
3
+ status: number;
4
+ body: unknown;
5
+ }>;
@@ -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 { fetchSpecs, fetchSpecFromUrl, } from "./openapi-loader.js";
13
- import { getAccessToken } from "./auth.js";
14
- // ---------------------------------------------------------------------------
15
- // State
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
- switchToolDef,
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
- // Handle SwitchEnvironment
346
- if (name === SWITCH_TOOL_NAME) {
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) {
@@ -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 {};
@@ -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 (error) {
181
- // Spec file doesn't exist or is invalid, skip
143
+ catch {
182
144
  continue;
183
145
  }
184
146
  }
@@ -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,2 @@
1
+ import { type ApiEndpoint } from "./openapi-loader.js";
2
+ export declare function buildInputSchema(endpoint: ApiEndpoint): Record<string, unknown>;
@@ -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": "0.130.0",
3
+ "version": "0.131.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": {