@snokam/mcp-server 0.1.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 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
+ }
@@ -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,176 @@
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, } 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({ name: "snokam", version: "0.1.0" }, { capabilities: { tools: {} } });
124
+ // List tools
125
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
126
+ tools: endpoints.map((ep) => ({
127
+ name: ep.toolName,
128
+ description: ep.description || ep.summary || `${ep.method} ${ep.path}`,
129
+ inputSchema: buildInputSchema(ep),
130
+ })),
131
+ }));
132
+ // Call tool
133
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
134
+ const { name, arguments: args = {} } = request.params;
135
+ const endpoint = endpointsByTool.get(name);
136
+ if (!endpoint) {
137
+ return {
138
+ content: [{ type: "text", text: `Unknown tool: ${name}` }],
139
+ isError: true,
140
+ };
141
+ }
142
+ try {
143
+ const result = await executeCall(endpoint, args);
144
+ const text = typeof result.body === "string"
145
+ ? result.body
146
+ : JSON.stringify(result.body, null, 2);
147
+ return {
148
+ content: [
149
+ {
150
+ type: "text",
151
+ text: `HTTP ${result.status}\n\n${text}`,
152
+ },
153
+ ],
154
+ isError: result.status >= 400,
155
+ };
156
+ }
157
+ catch (error) {
158
+ return {
159
+ content: [
160
+ {
161
+ type: "text",
162
+ text: `Request failed: ${error instanceof Error ? error.message : String(error)}`,
163
+ },
164
+ ],
165
+ isError: true,
166
+ };
167
+ }
168
+ });
169
+ const transport = new StdioServerTransport();
170
+ await server.connect(transport);
171
+ console.error("[snokam-mcp] Server running on stdio");
172
+ }
173
+ main().catch((error) => {
174
+ console.error("[snokam-mcp] Fatal error:", error);
175
+ process.exit(1);
176
+ });
@@ -0,0 +1,45 @@
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
+ toolName: string;
12
+ operationId: string;
13
+ method: string;
14
+ path: string;
15
+ baseUrl: string;
16
+ summary: string;
17
+ description: string;
18
+ parameters: OpenApiParameter[];
19
+ requestBody: OpenApiRequestBody | null;
20
+ /** OAuth2 scope in `.default` format for OBO exchange, or null for public endpoints. */
21
+ scope: string | null;
22
+ }
23
+ interface OpenApiParameter {
24
+ name: string;
25
+ in: "query" | "path" | "header";
26
+ description?: string;
27
+ required?: boolean;
28
+ schema?: {
29
+ type?: string;
30
+ format?: string;
31
+ enum?: string[];
32
+ items?: {
33
+ type?: string;
34
+ };
35
+ };
36
+ }
37
+ interface OpenApiRequestBody {
38
+ description?: string;
39
+ required?: boolean;
40
+ content?: Record<string, {
41
+ schema?: Record<string, unknown>;
42
+ }>;
43
+ }
44
+ export declare function fetchSpecs(environment: string): Promise<ApiEndpoint[]>;
45
+ export {};
@@ -0,0 +1,126 @@
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
+ // Known services (functions that expose OpenAPI specs)
11
+ // ---------------------------------------------------------------------------
12
+ const SERVICES = [
13
+ "accounting",
14
+ "broker",
15
+ "calculators",
16
+ "chatgpt",
17
+ "crypto",
18
+ "employees",
19
+ "events",
20
+ "office",
21
+ "platform",
22
+ "power-office",
23
+ "sync",
24
+ "webshop",
25
+ ];
26
+ // ---------------------------------------------------------------------------
27
+ // Environment-aware URL resolution
28
+ // ---------------------------------------------------------------------------
29
+ const PROD_DOMAIN = "api.snokam.no";
30
+ const TEST_DOMAIN = "api.test.snokam.no";
31
+ function getBaseDomain(environment) {
32
+ return environment === "test" ? TEST_DOMAIN : PROD_DOMAIN;
33
+ }
34
+ function getBaseUrl(service, environment) {
35
+ return `https://${service}.${getBaseDomain(environment)}`;
36
+ }
37
+ // ---------------------------------------------------------------------------
38
+ // Scope extraction
39
+ // ---------------------------------------------------------------------------
40
+ function extractScope(operation) {
41
+ for (const secReq of operation.security ?? []) {
42
+ for (const scopes of Object.values(secReq)) {
43
+ if (!Array.isArray(scopes))
44
+ continue;
45
+ for (const s of scopes) {
46
+ if (s.startsWith("api://")) {
47
+ const base = s.substring(0, s.lastIndexOf("/"));
48
+ return `${base}/.default`;
49
+ }
50
+ }
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+ // ---------------------------------------------------------------------------
56
+ // Tool naming
57
+ // ---------------------------------------------------------------------------
58
+ function makeToolName(service, operationId) {
59
+ return `${service}__${operationId}`;
60
+ }
61
+ // ---------------------------------------------------------------------------
62
+ // Spec parsing
63
+ // ---------------------------------------------------------------------------
64
+ function parseSpec(spec, service, baseUrl) {
65
+ const endpoints = [];
66
+ const paths = spec.paths;
67
+ if (!paths || Object.keys(paths).length === 0)
68
+ return endpoints;
69
+ for (const [path, pathItem] of Object.entries(paths)) {
70
+ for (const method of ["get", "post", "put", "patch", "delete"]) {
71
+ const operation = pathItem[method];
72
+ if (!operation)
73
+ continue;
74
+ const operationId = operation.operationId ?? `${method}_${path.replace(/\//g, "_")}`;
75
+ const summary = operation.summary ?? "";
76
+ const description = operation.description ?? summary;
77
+ const scope = extractScope(operation);
78
+ endpoints.push({
79
+ service,
80
+ toolName: makeToolName(service, operationId),
81
+ operationId,
82
+ method: method.toUpperCase(),
83
+ path,
84
+ baseUrl,
85
+ summary,
86
+ description,
87
+ parameters: operation.parameters ?? [],
88
+ requestBody: operation.requestBody ?? null,
89
+ scope,
90
+ });
91
+ }
92
+ }
93
+ return endpoints;
94
+ }
95
+ // ---------------------------------------------------------------------------
96
+ // Public API
97
+ // ---------------------------------------------------------------------------
98
+ export async function fetchSpecs(environment) {
99
+ const endpoints = [];
100
+ const results = await Promise.allSettled(SERVICES.map(async (service) => {
101
+ const baseUrl = getBaseUrl(service, environment);
102
+ const swaggerUrl = `${baseUrl}/swagger.json`;
103
+ const response = await fetch(swaggerUrl, {
104
+ signal: AbortSignal.timeout(10_000),
105
+ });
106
+ if (!response.ok) {
107
+ throw new Error(`HTTP ${response.status}`);
108
+ }
109
+ const spec = (await response.json());
110
+ return { service, baseUrl, spec };
111
+ }));
112
+ let successCount = 0;
113
+ for (const result of results) {
114
+ if (result.status === "fulfilled") {
115
+ const { service, baseUrl, spec } = result.value;
116
+ endpoints.push(...parseSpec(spec, service, baseUrl));
117
+ successCount++;
118
+ }
119
+ else {
120
+ const idx = results.indexOf(result);
121
+ console.error(`[snokam-mcp] Failed to fetch ${SERVICES[idx]}: ${result.reason}`);
122
+ }
123
+ }
124
+ console.error(`[snokam-mcp] Loaded ${endpoints.length} endpoints from ${successCount}/${SERVICES.length} services (env=${environment})`);
125
+ return endpoints;
126
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@snokam/mcp-server",
3
+ "version": "0.1.0",
4
+ "description": "MCP server exposing Snokam backend APIs as tools",
5
+ "type": "module",
6
+ "bin": {
7
+ "snokam-mcp": "./dist/index.js"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "dev": "tsc --watch",
13
+ "start": "node dist/index.js"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "specs"
18
+ ],
19
+ "dependencies": {
20
+ "@azure/identity": "^4.6.0",
21
+ "@modelcontextprotocol/sdk": "^1.12.1",
22
+ "zod": "^3.24.4"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^22.15.0",
26
+ "typescript": "~5.8.3"
27
+ }
28
+ }