@12-apps/mcp 1.13.0 → 1.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/mcp",
3
- "version": "1.13.0",
3
+ "version": "1.15.0",
4
4
  "type": "module",
5
5
  "description": "App-agnostic MCP server core: generate one MCP tool per OpenAPI operation and proxy each call to the endpoint carrying the caller's bearer token (permission passthrough). Also ships the reusable AI-connect onboarding UI at @12-apps/mcp/react.",
6
6
  "exports": {
@@ -17,8 +17,8 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "@mui/icons-material": "^6.5.0",
20
- "@12-apps/onboarding": "^1.13.0",
21
- "@12-apps/ui": "^1.13.0",
20
+ "@12-apps/onboarding": "^1.15.0",
21
+ "@12-apps/ui": "^1.15.0",
22
22
  "react": "^19.2.0"
23
23
  },
24
24
  "peerDependencies": {
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "devDependencies": {
28
28
  "@mui/material": "^6.5.0",
29
- "@12-apps/typescript-config": "^1.13.0",
29
+ "@12-apps/typescript-config": "^1.16.0",
30
30
  "@types/node": "^22.15.3",
31
31
  "@types/react": "19.2.2",
32
32
  "eslint": "^9.39.1",
package/src/index.ts CHANGED
@@ -48,6 +48,17 @@ export {
48
48
  serializeManifest,
49
49
  type BuildManifestOptions,
50
50
  } from "./server/manifest";
51
+ // The advertised-version guard: a server whose `serverInfo.version` never moves
52
+ // gives a connected host no reason to re-read `tools/list`, so a shipped tool
53
+ // stays invisible to it. These let a consumer's generator make that bump a build
54
+ // error instead of a comment nobody reads — see `server/surface-lock.ts`.
55
+ export {
56
+ serializeSurfaceLock,
57
+ surfaceDigest,
58
+ surfaceLockProblem,
59
+ type SurfaceLock,
60
+ type SurfaceLockCheck,
61
+ } from "./server/surface-lock";
51
62
  // OAuth discovery — both halves of the MCP auth story kept together so the
52
63
  // future `@12-apps/mcp` extraction inherits them as one surface:
53
64
  // RFC 9728 protected-resource metadata + RFC 8414 authorization-server metadata.
@@ -0,0 +1,121 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { buildManifest, serializeManifest } from "./manifest";
4
+ import type { GeneratedTool } from "../types";
5
+
6
+ /**
7
+ * Making a server's advertised version impossible to leave behind.
8
+ *
9
+ * THE PROBLEM, which every MCP server on this transport has. `tools/list` is
10
+ * answered on request and there is no server→client stream, so a server cannot
11
+ * push `notifications/tools/list_changed` — and one that declares
12
+ * `capabilities.tools.listChanged` without being able to send it is worse than
13
+ * one that is honest, because the host then stops checking for itself. What is
14
+ * left is `serverInfo.version` from the `initialize` handshake. A host caches the
15
+ * tool list against it, so a version that never moves gives it no reason to ever
16
+ * ask again: a tool that shipped stays invisible to every ALREADY CONNECTED
17
+ * client for as long as that connection lives.
18
+ *
19
+ * That is not a hypothetical. In `future-pay` a new tool reached production,
20
+ * answered on its route, and did not appear in a live connector — behind a
21
+ * `serverInfo.version` frozen at its initial value while ~280 tools were added
22
+ * underneath it. Nothing was broken; the only thing asking anyone to bump it was
23
+ * a comment, and a rule enforced by a comment is not enforced.
24
+ *
25
+ * THE MECHANISM. An app commits a lock recording WHICH surface its current
26
+ * version stands for. Its generator recomputes the digest and refuses to write
27
+ * the artifacts when the digest moved while the version did not, naming the
28
+ * value to set. Because the same generator run under `--check` is what the
29
+ * contract gate already diffs, the failure lands in CI and in a pre-push hook
30
+ * without a new job, without git history, and without any event-sensitivity.
31
+ *
32
+ * WHY A DIGEST OF THE SURFACE, NOT A PATHS FILTER. A `paths:` list over the
33
+ * server's own directory is wrong in both directions: it fires on edits no
34
+ * client can see (a comment in an auth helper) and misses real ones that enter
35
+ * from outside it (a schema whose ceiling is imported from a storage module).
36
+ * Hashing what the tools ARE — the canonical manifest serialization — has
37
+ * neither failure mode: it is exactly the bytes `tools/list` would return.
38
+ */
39
+
40
+ /** The committed record: which surface an app's current version stands for. */
41
+ export interface SurfaceLock {
42
+ /** The app's surface version at the time `digest` was recorded. */
43
+ version: number;
44
+ /** Digest of the served tool surface (see {@link surfaceDigest}). */
45
+ digest: string;
46
+ }
47
+
48
+ /** Everything {@link surfaceLockProblem} needs to judge one generation. */
49
+ export interface SurfaceLockCheck {
50
+ /** The lock as committed, or `null` when there is none to contradict. */
51
+ previous: SurfaceLock | null;
52
+ /** The surface version the app currently declares. */
53
+ version: number;
54
+ /** Digest of the surface being generated now. */
55
+ digest: string;
56
+ /**
57
+ * Where the app's version constant lives, repo-relative — quoted in the
58
+ * failure so the fix is a path and a value rather than a hunt.
59
+ */
60
+ versionLocation: string;
61
+ /** The constant's name, if the app does not use the default. */
62
+ versionName?: string;
63
+ }
64
+
65
+ /**
66
+ * Version pinned out of the digest input. The digest answers "did the SURFACE
67
+ * change?", so it must not move merely because the version did — otherwise
68
+ * bumping would re-satisfy the check by itself and the gate would prove nothing.
69
+ */
70
+ const DIGEST_VERSION_SENTINEL = 0;
71
+
72
+ /** Length of the hex digest kept. Collision risk here is not adversarial. */
73
+ const DIGEST_LENGTH = 16;
74
+
75
+ const DEFAULT_VERSION_NAME = "MCP_SURFACE_VERSION";
76
+
77
+ /**
78
+ * Digest of a served tool surface — every tool's name, description, annotations
79
+ * and input/output schemas, in the manifest's own canonical (deep-key-sorted)
80
+ * serialization. Stable across unrelated reordering, and identical for two
81
+ * surfaces that a client could not tell apart.
82
+ */
83
+ export function surfaceDigest(tools: GeneratedTool[], source: string): string {
84
+ const canonical = serializeManifest(
85
+ buildManifest(tools, { version: DIGEST_VERSION_SENTINEL, source }),
86
+ );
87
+ return createHash("sha256").update(canonical).digest("hex").slice(0, DIGEST_LENGTH);
88
+ }
89
+
90
+ /** Canonical JSON for a committed lock (trailing newline, like the manifest). */
91
+ export function serializeSurfaceLock(lock: SurfaceLock): string {
92
+ return `${JSON.stringify(lock, null, 2)}\n`;
93
+ }
94
+
95
+ /**
96
+ * Decide whether an app's current version may stand for its current surface.
97
+ *
98
+ * Returns the problem as a sentence ready to print, or `null` when the pair is
99
+ * consistent. Four outcomes, and three of them pass:
100
+ *
101
+ * - surface unchanged → fine, whatever the version did (a release bump with no
102
+ * surface change is legitimate and must not be blocked);
103
+ * - surface changed AND version moved → fine, that is the whole contract;
104
+ * - no lock to contradict (first run, or the file was deleted) → fine, it is
105
+ * simply recorded;
106
+ * - surface changed and version did not → the failure this exists for.
107
+ */
108
+ export function surfaceLockProblem(check: SurfaceLockCheck): string | null {
109
+ const { previous, version, digest, versionLocation } = check;
110
+ if (!previous || previous.digest === digest || previous.version !== version) {
111
+ return null;
112
+ }
113
+ const name = check.versionName ?? DEFAULT_VERSION_NAME;
114
+ return (
115
+ `the served tool surface changed but ${name} is still ${version}.\n` +
116
+ ` A connected client is told this number on initialize and caches tools/list against it, so\n` +
117
+ ` leaving it put ships the new surface to a client that will never ask for it again.\n` +
118
+ ` Set ${name} = ${version + 1} in ${versionLocation}, then re-run.\n` +
119
+ ` (surface ${previous.digest} → ${digest})`
120
+ );
121
+ }