@snokam/mcp-api 0.5.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 +16 -0
- package/dist/auth.js +72 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +271 -0
- package/dist/openapi-loader.d.ts +46 -0
- package/dist/openapi-loader.js +174 -0
- package/package.json +35 -0
- package/specs/production/accounting.json +1131 -0
- package/specs/production/broker.json +109 -0
- package/specs/production/calculators.json +1523 -0
- package/specs/production/chatgpt.json +1655 -0
- package/specs/production/crypto.json +1998 -0
- package/specs/production/employees.json +1867 -0
- package/specs/production/events.json +2322 -0
- package/specs/production/notifications.json +270 -0
- package/specs/production/office.json +1984 -0
- package/specs/production/power-office.json +2383 -0
- package/specs/production/sanity.json +29509 -0
- package/specs/production/sync.json +181 -0
- package/specs/production/webshop.json +631 -0
- package/specs/test/accounting.json +1131 -0
- package/specs/test/broker.json +109 -0
- package/specs/test/calculators.json +1523 -0
- package/specs/test/chatgpt.json +1655 -0
- package/specs/test/crypto.json +1998 -0
- package/specs/test/employees.json +1867 -0
- package/specs/test/events.json +2322 -0
- package/specs/test/notifications.json +270 -0
- package/specs/test/power-office.json +2383 -0
- package/specs/test/sanity.json +29509 -0
- package/specs/test/sync.json +181 -0
- package/specs/test/webshop.json +631 -0
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token acquisition for Snokam backend APIs.
|
|
3
|
+
*
|
|
4
|
+
* Supports three modes (auto-detected):
|
|
5
|
+
*
|
|
6
|
+
* 1. **OBO (On-Behalf-Of):** When SNOKAM_USER_JWT is set, exchanges the user
|
|
7
|
+
* JWT for a service-specific token via Azure AD OBO flow.
|
|
8
|
+
* Requires AZURE_AD_CLIENT_ID, AZURE_AD_TENANT_ID, and either
|
|
9
|
+
* AZURE_AD_SECRET (client secret) or managed identity.
|
|
10
|
+
*
|
|
11
|
+
* 2. **DefaultAzureCredential:** When no user JWT is present, falls back to
|
|
12
|
+
* Azure CLI (local dev), managed identity (Azure), etc.
|
|
13
|
+
*
|
|
14
|
+
* 3. **No auth:** Endpoints without a scope get no Authorization header.
|
|
15
|
+
*/
|
|
16
|
+
export declare function getAccessToken(scope: string | null): Promise<string | null>;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token acquisition for Snokam backend APIs.
|
|
3
|
+
*
|
|
4
|
+
* Supports three modes (auto-detected):
|
|
5
|
+
*
|
|
6
|
+
* 1. **OBO (On-Behalf-Of):** When SNOKAM_USER_JWT is set, exchanges the user
|
|
7
|
+
* JWT for a service-specific token via Azure AD OBO flow.
|
|
8
|
+
* Requires AZURE_AD_CLIENT_ID, AZURE_AD_TENANT_ID, and either
|
|
9
|
+
* AZURE_AD_SECRET (client secret) or managed identity.
|
|
10
|
+
*
|
|
11
|
+
* 2. **DefaultAzureCredential:** When no user JWT is present, falls back to
|
|
12
|
+
* Azure CLI (local dev), managed identity (Azure), etc.
|
|
13
|
+
*
|
|
14
|
+
* 3. **No auth:** Endpoints without a scope get no Authorization header.
|
|
15
|
+
*/
|
|
16
|
+
import { DefaultAzureCredential, OnBehalfOfCredential, } from "@azure/identity";
|
|
17
|
+
// Cache credentials per scope to avoid re-creating them
|
|
18
|
+
const credentialCache = new Map();
|
|
19
|
+
function getOboCredential(userJwt, clientId, tenantId) {
|
|
20
|
+
const clientSecret = process.env.AZURE_AD_SECRET;
|
|
21
|
+
if (clientSecret) {
|
|
22
|
+
return new OnBehalfOfCredential({
|
|
23
|
+
tenantId,
|
|
24
|
+
clientId,
|
|
25
|
+
clientSecret,
|
|
26
|
+
userAssertionToken: userJwt,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
// Managed identity as client assertion (federated identity)
|
|
30
|
+
const mi = new DefaultAzureCredential();
|
|
31
|
+
return new OnBehalfOfCredential({
|
|
32
|
+
tenantId,
|
|
33
|
+
clientId,
|
|
34
|
+
userAssertionToken: userJwt,
|
|
35
|
+
getAssertion: async () => {
|
|
36
|
+
const token = await mi.getToken("api://AzureADTokenExchange");
|
|
37
|
+
return token.token;
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export async function getAccessToken(scope) {
|
|
42
|
+
if (!scope)
|
|
43
|
+
return null;
|
|
44
|
+
const userJwt = process.env.SNOKAM_USER_JWT;
|
|
45
|
+
const clientId = process.env.AZURE_AD_CLIENT_ID ?? "";
|
|
46
|
+
const tenantId = process.env.AZURE_AD_TENANT_ID ?? "";
|
|
47
|
+
let credential;
|
|
48
|
+
if (userJwt && clientId && tenantId) {
|
|
49
|
+
// OBO mode
|
|
50
|
+
const cacheKey = `obo:${scope}`;
|
|
51
|
+
if (!credentialCache.has(cacheKey)) {
|
|
52
|
+
credentialCache.set(cacheKey, getOboCredential(userJwt, clientId, tenantId));
|
|
53
|
+
}
|
|
54
|
+
credential = credentialCache.get(cacheKey);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
// Default credential (Azure CLI locally, managed identity in Azure)
|
|
58
|
+
const cacheKey = "default";
|
|
59
|
+
if (!credentialCache.has(cacheKey)) {
|
|
60
|
+
credentialCache.set(cacheKey, new DefaultAzureCredential());
|
|
61
|
+
}
|
|
62
|
+
credential = credentialCache.get(cacheKey);
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const token = await credential.getToken(scope);
|
|
66
|
+
return token?.token ?? null;
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
console.error(`[snokam-mcp] Failed to get token for scope ${scope}:`, error instanceof Error ? error.message : error);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
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
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
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
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
10
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
12
|
+
import { fetchSpecs } from "./openapi-loader.js";
|
|
13
|
+
import { getAccessToken } from "./auth.js";
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Config
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
const ENVIRONMENT = process.env.SNOKAM_ENVIRONMENT ?? "production";
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// JSON Schema builder for tool inputs
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
function buildInputSchema(endpoint) {
|
|
22
|
+
const properties = {};
|
|
23
|
+
const required = [];
|
|
24
|
+
for (const param of endpoint.parameters) {
|
|
25
|
+
const prop = {};
|
|
26
|
+
if (param.schema?.type)
|
|
27
|
+
prop.type = param.schema.type;
|
|
28
|
+
if (param.schema?.enum)
|
|
29
|
+
prop.enum = param.schema.enum;
|
|
30
|
+
if (param.schema?.format)
|
|
31
|
+
prop.format = param.schema.format;
|
|
32
|
+
if (param.schema?.items)
|
|
33
|
+
prop.items = param.schema.items;
|
|
34
|
+
if (param.description)
|
|
35
|
+
prop.description = param.description;
|
|
36
|
+
if (!prop.type)
|
|
37
|
+
prop.type = "string";
|
|
38
|
+
properties[param.name] = prop;
|
|
39
|
+
if (param.required)
|
|
40
|
+
required.push(param.name);
|
|
41
|
+
}
|
|
42
|
+
if (endpoint.requestBody) {
|
|
43
|
+
properties.body = {
|
|
44
|
+
type: "object",
|
|
45
|
+
description: endpoint.requestBody.description ?? "Request body",
|
|
46
|
+
};
|
|
47
|
+
// Extract schema from content type if available
|
|
48
|
+
const jsonContent = endpoint.requestBody.content?.["application/json"];
|
|
49
|
+
if (jsonContent?.schema) {
|
|
50
|
+
properties.body = {
|
|
51
|
+
...properties.body,
|
|
52
|
+
...jsonContent.schema,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (endpoint.requestBody.required)
|
|
56
|
+
required.push("body");
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
type: "object",
|
|
60
|
+
properties,
|
|
61
|
+
required: required.length > 0 ? required : undefined,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// HTTP call execution
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
async function executeCall(endpoint, args) {
|
|
68
|
+
let url = `${endpoint.baseUrl}${endpoint.path}`;
|
|
69
|
+
const queryParams = [];
|
|
70
|
+
for (const param of endpoint.parameters) {
|
|
71
|
+
const value = args[param.name];
|
|
72
|
+
if (value === undefined)
|
|
73
|
+
continue;
|
|
74
|
+
if (param.in === "path") {
|
|
75
|
+
url = url.replace(`{${param.name}}`, encodeURIComponent(String(value)));
|
|
76
|
+
}
|
|
77
|
+
else if (param.in === "query") {
|
|
78
|
+
queryParams.push(`${encodeURIComponent(param.name)}=${encodeURIComponent(String(value))}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (queryParams.length > 0) {
|
|
82
|
+
url += `?${queryParams.join("&")}`;
|
|
83
|
+
}
|
|
84
|
+
const headers = {
|
|
85
|
+
Accept: "application/json",
|
|
86
|
+
};
|
|
87
|
+
const token = await getAccessToken(endpoint.scope);
|
|
88
|
+
if (token) {
|
|
89
|
+
headers.Authorization = `Bearer ${token}`;
|
|
90
|
+
}
|
|
91
|
+
let fetchBody;
|
|
92
|
+
if (args.body !== undefined && endpoint.method !== "GET") {
|
|
93
|
+
headers["Content-Type"] = "application/json";
|
|
94
|
+
fetchBody = JSON.stringify(args.body);
|
|
95
|
+
}
|
|
96
|
+
const response = await fetch(url, {
|
|
97
|
+
method: endpoint.method,
|
|
98
|
+
headers,
|
|
99
|
+
body: fetchBody,
|
|
100
|
+
});
|
|
101
|
+
let body;
|
|
102
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
103
|
+
if (contentType.includes("application/json")) {
|
|
104
|
+
body = await response.json();
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
body = await response.text();
|
|
108
|
+
}
|
|
109
|
+
return { status: response.status, body };
|
|
110
|
+
}
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Server setup
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
async function main() {
|
|
115
|
+
const endpoints = await fetchSpecs(ENVIRONMENT);
|
|
116
|
+
if (endpoints.length === 0) {
|
|
117
|
+
console.error("[snokam-mcp] No endpoints loaded. Ensure specs/*.json files exist.");
|
|
118
|
+
}
|
|
119
|
+
const endpointsByTool = new Map();
|
|
120
|
+
for (const ep of endpoints) {
|
|
121
|
+
endpointsByTool.set(ep.toolName, ep);
|
|
122
|
+
}
|
|
123
|
+
const server = new Server({
|
|
124
|
+
name: "snokam",
|
|
125
|
+
version: "0.2.0",
|
|
126
|
+
}, {
|
|
127
|
+
capabilities: {
|
|
128
|
+
tools: {},
|
|
129
|
+
resources: {},
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
// List resources
|
|
133
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
134
|
+
resources: [
|
|
135
|
+
{
|
|
136
|
+
uri: "snokam://about",
|
|
137
|
+
name: "About Snøkam",
|
|
138
|
+
description: "Information about Snøkam and available API services",
|
|
139
|
+
mimeType: "text/markdown",
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
}));
|
|
143
|
+
// Read resource
|
|
144
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
145
|
+
const { uri } = request.params;
|
|
146
|
+
if (uri === "snokam://about") {
|
|
147
|
+
// Group endpoints by service with their descriptions
|
|
148
|
+
const serviceMap = new Map();
|
|
149
|
+
for (const ep of endpoints) {
|
|
150
|
+
const existing = serviceMap.get(ep.service);
|
|
151
|
+
if (existing) {
|
|
152
|
+
existing.count++;
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
serviceMap.set(ep.service, {
|
|
156
|
+
count: 1,
|
|
157
|
+
description: ep.serviceDescription,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const serviceList = Array.from(serviceMap.entries())
|
|
162
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
163
|
+
.map(([service, { count, description }]) => {
|
|
164
|
+
return `- **${service}** (${count} endpoints): ${description}`;
|
|
165
|
+
})
|
|
166
|
+
.join("\n");
|
|
167
|
+
const about = `# Snøkam MCP Server
|
|
168
|
+
|
|
169
|
+
**Snøkam** is a Norwegian software consulting company. This MCP server provides programmatic access to Snøkam's internal backend APIs.
|
|
170
|
+
|
|
171
|
+
## Environment
|
|
172
|
+
|
|
173
|
+
Currently connected to: **${ENVIRONMENT}**
|
|
174
|
+
|
|
175
|
+
## Available Services
|
|
176
|
+
|
|
177
|
+
${serviceList}
|
|
178
|
+
|
|
179
|
+
## Authentication
|
|
180
|
+
|
|
181
|
+
- **Public endpoints**: No authentication required (e.g., \`employees__GetEmployeesPublic\`)
|
|
182
|
+
- **Protected endpoints**: Require Azure AD authentication (e.g., \`employees__GetEmployeesProtected\`)
|
|
183
|
+
|
|
184
|
+
## Common Use Cases
|
|
185
|
+
|
|
186
|
+
**Find out who works at Snøkam:**
|
|
187
|
+
\`\`\`
|
|
188
|
+
Use: employees__GetEmployeesPublic
|
|
189
|
+
Returns: List of all employees with names, roles, and technologies
|
|
190
|
+
\`\`\`
|
|
191
|
+
|
|
192
|
+
**Get upcoming events:**
|
|
193
|
+
\`\`\`
|
|
194
|
+
Use: events__GetPublicEvents (if available)
|
|
195
|
+
Returns: Company events and gatherings
|
|
196
|
+
\`\`\`
|
|
197
|
+
|
|
198
|
+
**Check office status or control music:**
|
|
199
|
+
\`\`\`
|
|
200
|
+
Use: office__* tools
|
|
201
|
+
Controls: Sonos speakers, lights, YouTube queue
|
|
202
|
+
\`\`\`
|
|
203
|
+
`;
|
|
204
|
+
return {
|
|
205
|
+
contents: [
|
|
206
|
+
{
|
|
207
|
+
uri,
|
|
208
|
+
mimeType: "text/markdown",
|
|
209
|
+
text: about,
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
contents: [],
|
|
216
|
+
isError: true,
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
// List tools
|
|
220
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
221
|
+
tools: endpoints.map((ep) => ({
|
|
222
|
+
name: ep.toolName,
|
|
223
|
+
description: ep.description || ep.summary || `${ep.method} ${ep.path}`,
|
|
224
|
+
inputSchema: buildInputSchema(ep),
|
|
225
|
+
})),
|
|
226
|
+
}));
|
|
227
|
+
// Call tool
|
|
228
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
229
|
+
const { name, arguments: args = {} } = request.params;
|
|
230
|
+
const endpoint = endpointsByTool.get(name);
|
|
231
|
+
if (!endpoint) {
|
|
232
|
+
return {
|
|
233
|
+
content: [{ type: "text", text: `Unknown tool: ${name}` }],
|
|
234
|
+
isError: true,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
const result = await executeCall(endpoint, args);
|
|
239
|
+
const text = typeof result.body === "string"
|
|
240
|
+
? result.body
|
|
241
|
+
: JSON.stringify(result.body, null, 2);
|
|
242
|
+
return {
|
|
243
|
+
content: [
|
|
244
|
+
{
|
|
245
|
+
type: "text",
|
|
246
|
+
text: `HTTP ${result.status}\n\n${text}`,
|
|
247
|
+
},
|
|
248
|
+
],
|
|
249
|
+
isError: result.status >= 400,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
return {
|
|
254
|
+
content: [
|
|
255
|
+
{
|
|
256
|
+
type: "text",
|
|
257
|
+
text: `Request failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
isError: true,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
const transport = new StdioServerTransport();
|
|
265
|
+
await server.connect(transport);
|
|
266
|
+
console.error("[snokam-mcp] Server running on stdio");
|
|
267
|
+
}
|
|
268
|
+
main().catch((error) => {
|
|
269
|
+
console.error("[snokam-mcp] Fatal error:", error);
|
|
270
|
+
process.exit(1);
|
|
271
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
export interface ApiEndpoint {
|
|
10
|
+
service: string;
|
|
11
|
+
serviceDescription: string;
|
|
12
|
+
toolName: string;
|
|
13
|
+
operationId: string;
|
|
14
|
+
method: string;
|
|
15
|
+
path: string;
|
|
16
|
+
baseUrl: string;
|
|
17
|
+
summary: string;
|
|
18
|
+
description: string;
|
|
19
|
+
parameters: OpenApiParameter[];
|
|
20
|
+
requestBody: OpenApiRequestBody | null;
|
|
21
|
+
/** OAuth2 scope in `.default` format for OBO exchange, or null for public endpoints. */
|
|
22
|
+
scope: string | null;
|
|
23
|
+
}
|
|
24
|
+
interface OpenApiParameter {
|
|
25
|
+
name: string;
|
|
26
|
+
in: "query" | "path" | "header";
|
|
27
|
+
description?: string;
|
|
28
|
+
required?: boolean;
|
|
29
|
+
schema?: {
|
|
30
|
+
type?: string;
|
|
31
|
+
format?: string;
|
|
32
|
+
enum?: string[];
|
|
33
|
+
items?: {
|
|
34
|
+
type?: string;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
interface OpenApiRequestBody {
|
|
39
|
+
description?: string;
|
|
40
|
+
required?: boolean;
|
|
41
|
+
content?: Record<string, {
|
|
42
|
+
schema?: Record<string, unknown>;
|
|
43
|
+
}>;
|
|
44
|
+
}
|
|
45
|
+
export declare function fetchSpecs(environment: string): Promise<ApiEndpoint[]>;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,174 @@
|
|
|
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
|
+
async function discoverServices(environment) {
|
|
13
|
+
try {
|
|
14
|
+
const { readdir } = await import("fs/promises");
|
|
15
|
+
const { fileURLToPath } = await import("url");
|
|
16
|
+
const { dirname, join } = await import("path");
|
|
17
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const specsDir = join(__dirname, "..", "specs", environment);
|
|
19
|
+
const files = await readdir(specsDir);
|
|
20
|
+
return files
|
|
21
|
+
.filter((f) => f.endsWith(".json"))
|
|
22
|
+
.map((f) => f.replace(".json", ""))
|
|
23
|
+
.sort();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// No bundled specs found - return empty array
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Environment-aware URL resolution
|
|
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;
|
|
37
|
+
}
|
|
38
|
+
function getBaseUrl(service, environment) {
|
|
39
|
+
return `https://${service}.${getBaseDomain(environment)}`;
|
|
40
|
+
}
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Scope extraction
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
function extractScope(operation) {
|
|
45
|
+
for (const secReq of operation.security ?? []) {
|
|
46
|
+
for (const scopes of Object.values(secReq)) {
|
|
47
|
+
if (!Array.isArray(scopes))
|
|
48
|
+
continue;
|
|
49
|
+
for (const s of scopes) {
|
|
50
|
+
if (s.startsWith("api://")) {
|
|
51
|
+
const base = s.substring(0, s.lastIndexOf("/"));
|
|
52
|
+
return `${base}/.default`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Tool naming
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
function makeToolName(service, operationId) {
|
|
63
|
+
return `${service}__${operationId}`;
|
|
64
|
+
}
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Spec parsing
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
function parseSpec(spec, service, baseUrl) {
|
|
69
|
+
const endpoints = [];
|
|
70
|
+
const paths = spec.paths;
|
|
71
|
+
if (!paths || Object.keys(paths).length === 0)
|
|
72
|
+
return endpoints;
|
|
73
|
+
const serviceDescription = spec.info.description || spec.info.title || "";
|
|
74
|
+
for (const [path, pathItem] of Object.entries(paths)) {
|
|
75
|
+
for (const method of ["get", "post", "put", "patch", "delete"]) {
|
|
76
|
+
const operation = pathItem[method];
|
|
77
|
+
if (!operation)
|
|
78
|
+
continue;
|
|
79
|
+
const operationId = operation.operationId ?? `${method}_${path.replace(/\//g, "_")}`;
|
|
80
|
+
const summary = operation.summary ?? "";
|
|
81
|
+
const description = operation.description ?? summary;
|
|
82
|
+
const scope = extractScope(operation);
|
|
83
|
+
endpoints.push({
|
|
84
|
+
service,
|
|
85
|
+
serviceDescription,
|
|
86
|
+
toolName: makeToolName(service, operationId),
|
|
87
|
+
operationId,
|
|
88
|
+
method: method.toUpperCase(),
|
|
89
|
+
path,
|
|
90
|
+
baseUrl,
|
|
91
|
+
summary,
|
|
92
|
+
description,
|
|
93
|
+
parameters: operation.parameters ?? [],
|
|
94
|
+
requestBody: operation.requestBody ?? null,
|
|
95
|
+
scope,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return endpoints;
|
|
100
|
+
}
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Public API
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
export async function fetchSpecs(environment) {
|
|
105
|
+
// Try to load bundled specs first (for speed)
|
|
106
|
+
try {
|
|
107
|
+
const bundledSpecs = await loadBundledSpecs(environment);
|
|
108
|
+
if (bundledSpecs.length > 0) {
|
|
109
|
+
console.error(`[snokam-mcp] Loaded ${bundledSpecs.length} endpoints from bundled specs (env=${environment})`);
|
|
110
|
+
return bundledSpecs;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
console.error(`[snokam-mcp] Failed to load bundled specs, falling back to live fetch:`, error);
|
|
115
|
+
}
|
|
116
|
+
// Fallback to live fetching (slower, for development)
|
|
117
|
+
// Discover services from environment to know which APIs to fetch
|
|
118
|
+
const services = await discoverServices(environment);
|
|
119
|
+
if (services.length === 0) {
|
|
120
|
+
console.error("[snokam-mcp] No services discovered. Unable to fetch specs.");
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
const endpoints = [];
|
|
124
|
+
const results = await Promise.allSettled(services.map(async (service) => {
|
|
125
|
+
const baseUrl = getBaseUrl(service, environment);
|
|
126
|
+
const swaggerUrl = `${baseUrl}/swagger.json`;
|
|
127
|
+
const response = await fetch(swaggerUrl, {
|
|
128
|
+
signal: AbortSignal.timeout(10_000),
|
|
129
|
+
});
|
|
130
|
+
if (!response.ok) {
|
|
131
|
+
throw new Error(`HTTP ${response.status}`);
|
|
132
|
+
}
|
|
133
|
+
const spec = (await response.json());
|
|
134
|
+
return { service, baseUrl, spec };
|
|
135
|
+
}));
|
|
136
|
+
let successCount = 0;
|
|
137
|
+
for (const result of results) {
|
|
138
|
+
if (result.status === "fulfilled") {
|
|
139
|
+
const { service, baseUrl, spec } = result.value;
|
|
140
|
+
endpoints.push(...parseSpec(spec, service, baseUrl));
|
|
141
|
+
successCount++;
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
const idx = results.indexOf(result);
|
|
145
|
+
console.error(`[snokam-mcp] Failed to fetch ${services[idx]}: ${result.reason}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
console.error(`[snokam-mcp] Loaded ${endpoints.length} endpoints from ${successCount}/${services.length} services (env=${environment})`);
|
|
149
|
+
return endpoints;
|
|
150
|
+
}
|
|
151
|
+
async function loadBundledSpecs(environment) {
|
|
152
|
+
const { readFile } = await import("fs/promises");
|
|
153
|
+
const { fileURLToPath } = await import("url");
|
|
154
|
+
const { dirname, join } = await import("path");
|
|
155
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
156
|
+
const specsDir = join(__dirname, "..", "specs", environment);
|
|
157
|
+
// Discover which specs are bundled
|
|
158
|
+
const services = await discoverServices(environment);
|
|
159
|
+
const endpoints = [];
|
|
160
|
+
for (const service of services) {
|
|
161
|
+
try {
|
|
162
|
+
const specPath = join(specsDir, `${service}.json`);
|
|
163
|
+
const specData = await readFile(specPath, "utf-8");
|
|
164
|
+
const spec = JSON.parse(specData);
|
|
165
|
+
const baseUrl = getBaseUrl(service, environment);
|
|
166
|
+
endpoints.push(...parseSpec(spec, service, baseUrl));
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
// Spec file doesn't exist or is invalid, skip
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return endpoints;
|
|
174
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@snokam/mcp-api",
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "MCP server exposing Snokam backend APIs as tools for Claude Code and other MCP clients",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"snokam-mcp": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"bundle-specs": "node scripts/bundle-specs.js",
|
|
12
|
+
"bundle-specs:prod": "node scripts/bundle-specs.js production",
|
|
13
|
+
"bundle-specs:test": "node scripts/bundle-specs.js test",
|
|
14
|
+
"prebuild": "npm run bundle-specs:prod && npm run bundle-specs:test",
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"dev": "tsc --watch",
|
|
17
|
+
"start": "node dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"specs"
|
|
22
|
+
],
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@azure/identity": "^4.6.0",
|
|
25
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
26
|
+
"zod": "^3.24.4"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^22.15.0",
|
|
30
|
+
"typescript": "~5.8.3"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
}
|
|
35
|
+
}
|