@snokam/mcp-api 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/auth.js CHANGED
@@ -2,8 +2,6 @@ import { readFileSync } from "fs";
2
2
  import { DefaultAzureCredential } from "@azure/identity";
3
3
  let defaultCredential = null;
4
4
  export async function getAccessToken(scope) {
5
- if (!scope)
6
- return null;
7
5
  const tokenFile = process.env.SNOKAM_ACCESS_TOKEN_FILE;
8
6
  if (tokenFile) {
9
7
  try {
@@ -16,6 +14,8 @@ export async function getAccessToken(scope) {
16
14
  const preMinted = process.env.SNOKAM_ACCESS_TOKEN;
17
15
  if (preMinted)
18
16
  return preMinted;
17
+ if (!scope)
18
+ return null;
19
19
  try {
20
20
  defaultCredential ??= new DefaultAzureCredential();
21
21
  const token = await defaultCredential.getToken(scope);
package/dist/index.js CHANGED
@@ -4,9 +4,12 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
5
  import { BUILTIN_TOOL_DEFS, BUILTIN_TOOL_NAMES, handleBuiltinTool, } from "./builtin-tools.js";
6
6
  import { executeCall } from "./execute-call.js";
7
+ import { settled } from "./state.js";
7
8
  import { currentEnvironment, endpoints, endpointsByTool, loadEndpoints, } from "./state.js";
8
9
  import { buildInputSchema } from "./tool-schema.js";
10
+ import { blockedReason, reportRequirements } from "./requirements.js";
9
11
  async function main() {
12
+ reportRequirements();
10
13
  await loadEndpoints(currentEnvironment);
11
14
  if (endpoints.length === 0) {
12
15
  console.error("[snokam-mcp] No endpoints loaded. Ensure specs/*.json files exist.");
@@ -117,7 +120,14 @@ Controls: Sonos speakers, lights, YouTube queue
117
120
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
118
121
  const { name, arguments: args = {} } = request.params;
119
122
  if (BUILTIN_TOOL_NAMES.has(name)) {
120
- return handleBuiltinTool(server, name, args);
123
+ const result = await handleBuiltinTool(server, name, args);
124
+ reportRequirements();
125
+ return result;
126
+ }
127
+ await settled();
128
+ const blocked = blockedReason();
129
+ if (blocked) {
130
+ return { content: [{ type: "text", text: blocked }], isError: true };
121
131
  }
122
132
  const endpoint = endpointsByTool.get(name);
123
133
  if (!endpoint) {
@@ -36,6 +36,24 @@ function extractScope(operation) {
36
36
  function makeToolName(service, operationId) {
37
37
  return `${service}__${operationId}`;
38
38
  }
39
+ function resourceOf(path) {
40
+ const segment = path
41
+ .split("/")
42
+ .find((part) => part && !part.startsWith("{") && !/^v[0-9]/.test(part));
43
+ return segment ? segment.replace(/[^a-zA-Z0-9]/g, "") : "";
44
+ }
45
+ function disambiguate(endpoints) {
46
+ const counts = new Map();
47
+ for (const ep of endpoints) {
48
+ counts.set(ep.toolName, (counts.get(ep.toolName) ?? 0) + 1);
49
+ }
50
+ return endpoints.map((ep) => {
51
+ if ((counts.get(ep.toolName) ?? 0) < 2)
52
+ return ep;
53
+ const resource = resourceOf(ep.path);
54
+ return resource ? { ...ep, toolName: `${ep.toolName}_${resource}` } : ep;
55
+ });
56
+ }
39
57
  function parseSpec(spec, service, baseUrl) {
40
58
  const endpoints = [];
41
59
  const paths = spec.paths;
@@ -67,7 +85,7 @@ function parseSpec(spec, service, baseUrl) {
67
85
  });
68
86
  }
69
87
  }
70
- return endpoints;
88
+ return disambiguate(endpoints);
71
89
  }
72
90
  export async function fetchSpecFromUrl(service, baseUrl) {
73
91
  const swaggerUrl = `${baseUrl}/swagger.json`;
@@ -0,0 +1,10 @@
1
+ export interface Requirement {
2
+ name: string;
3
+ ok: boolean;
4
+ detail: string;
5
+ fix?: string;
6
+ }
7
+ export declare function checkRequirements(): Requirement[];
8
+ export declare function unmetRequirements(): Requirement[];
9
+ export declare function reportRequirements(): Requirement[];
10
+ export declare function blockedReason(): string | null;
@@ -0,0 +1,92 @@
1
+ import { activeApiHost, apiHostOverride, currentEnvironment } from "./state.js";
2
+ import { SWITCH_DOMAIN_TOOL_NAME, SWITCH_TOOL_NAME, VALID_ENVIRONMENTS, } from "./builtin-tools.js";
3
+ const BUSINESS_HOST = /^[a-z0-9-]+\.(test\.)?snosky\.no$/;
4
+ function hostRequirement() {
5
+ const host = activeApiHost();
6
+ const explicit = process.env.SNOKAM_API_HOST?.trim();
7
+ const business = process.env.SNOKAM_BUSINESS?.trim();
8
+ if (apiHostOverride) {
9
+ return {
10
+ name: "api host",
11
+ ok: true,
12
+ detail: `${apiHostOverride} (set with ${SWITCH_DOMAIN_TOOL_NAME})`,
13
+ };
14
+ }
15
+ if (explicit) {
16
+ return { name: "api host", ok: true, detail: `${host} (SNOKAM_API_HOST)` };
17
+ }
18
+ if (business) {
19
+ return {
20
+ name: "api host",
21
+ ok: BUSINESS_HOST.test(host),
22
+ detail: `${host} (SNOKAM_BUSINESS=${business})`,
23
+ fix: BUSINESS_HOST.test(host)
24
+ ? undefined
25
+ : `"${business}" does not produce a business host. Call ${SWITCH_DOMAIN_TOOL_NAME} with the host itself, e.g. acme.snosky.no.`,
26
+ };
27
+ }
28
+ return {
29
+ name: "api host",
30
+ ok: false,
31
+ detail: `${host} — no business chosen`,
32
+ fix: `${host} is the marketing site, not a business API host: requests reach the frontend and come back as its 404 page, which is indistinguishable from a missing document. Call ${SWITCH_DOMAIN_TOOL_NAME} with the business host (e.g. snokam.snosky.no), or start with SNOKAM_BUSINESS=snokam.`,
33
+ };
34
+ }
35
+ function environmentRequirement() {
36
+ const ok = VALID_ENVIRONMENTS.includes(currentEnvironment);
37
+ return {
38
+ name: "environment",
39
+ ok,
40
+ detail: currentEnvironment,
41
+ fix: ok
42
+ ? undefined
43
+ : `Expected one of ${VALID_ENVIRONMENTS.join(", ")}. Call ${SWITCH_TOOL_NAME}, or set SNOKAM_ENVIRONMENT.`,
44
+ };
45
+ }
46
+ function credentialsRequirement() {
47
+ if (process.env.SNOKAM_ACCESS_TOKEN) {
48
+ return { name: "credentials", ok: true, detail: "SNOKAM_ACCESS_TOKEN" };
49
+ }
50
+ if (process.env.SNOKAM_ACCESS_TOKEN_FILE) {
51
+ return {
52
+ name: "credentials",
53
+ ok: true,
54
+ detail: "SNOKAM_ACCESS_TOKEN_FILE",
55
+ };
56
+ }
57
+ return {
58
+ name: "credentials",
59
+ ok: true,
60
+ detail: "Azure credentials (az login). Calls needing a token fail with 401 if none can be minted.",
61
+ };
62
+ }
63
+ export function checkRequirements() {
64
+ return [
65
+ hostRequirement(),
66
+ environmentRequirement(),
67
+ credentialsRequirement(),
68
+ ];
69
+ }
70
+ export function unmetRequirements() {
71
+ return checkRequirements().filter((requirement) => !requirement.ok);
72
+ }
73
+ export function reportRequirements() {
74
+ const requirements = checkRequirements();
75
+ for (const requirement of requirements) {
76
+ console.error(`[snokam-mcp] ${requirement.ok ? "✔" : "✖"} ${requirement.name}: ${requirement.detail}`);
77
+ if (requirement.fix)
78
+ console.error(`[snokam-mcp] ${requirement.fix}`);
79
+ }
80
+ return requirements;
81
+ }
82
+ export function blockedReason() {
83
+ const unmet = unmetRequirements();
84
+ if (unmet.length === 0)
85
+ return null;
86
+ return [
87
+ "This call was not sent, because the server is not in a state where it could succeed:",
88
+ ...unmet.map((r) => ` ✖ ${r.name}: ${r.detail}\n ${r.fix ?? ""}`.trimEnd()),
89
+ "",
90
+ "Fix the above, then call this tool again. Nothing else has changed.",
91
+ ].join("\n");
92
+ }
package/dist/state.d.ts CHANGED
@@ -7,6 +7,7 @@ export declare const serviceUrlOverrides: Map<string, string>;
7
7
  export declare function setApiHostOverride(host: string | null): void;
8
8
  export declare function resolveApiHost(environment: string): string;
9
9
  export declare function activeApiHost(): string;
10
+ export declare function settled(): Promise<void>;
10
11
  export declare function loadEndpoints(environment: string): Promise<void>;
11
12
  export declare function normalizeDomain(raw: string): string;
12
13
  export declare function rebuildEndpointsByTool(): void;
package/dist/state.js CHANGED
@@ -18,7 +18,19 @@ export function resolveApiHost(environment) {
18
18
  export function activeApiHost() {
19
19
  return apiHostOverride ?? resolveApiHost(currentEnvironment);
20
20
  }
21
- export async function loadEndpoints(environment) {
21
+ let reload = null;
22
+ export async function settled() {
23
+ while (reload)
24
+ await reload;
25
+ }
26
+ export function loadEndpoints(environment) {
27
+ reload = (async () => {
28
+ await load(environment);
29
+ reload = null;
30
+ })();
31
+ return reload;
32
+ }
33
+ async function load(environment) {
22
34
  currentEnvironment = environment;
23
35
  endpoints = await fetchSpecs(environment, activeApiHost());
24
36
  endpointsByTool = new Map();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snokam/mcp-api",
3
- "version": "2.0.0",
3
+ "version": "2.0.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": {