@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 +4 -4
- package/src/index.ts +11 -0
- package/src/server/surface-lock.ts +121 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/mcp",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
21
|
-
"@12-apps/ui": "^1.
|
|
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.
|
|
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
|
+
}
|