mcp-authz 0.1.0 → 0.3.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/README.md +553 -26
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +228 -0
- package/dist/index.d.ts +31 -209
- package/dist/index.js +417 -544
- package/dist/ladder-CUzOKudC.js +407 -0
- package/dist/ladder-D18eJ7tD.d.ts +32 -0
- package/dist/openapi.d.ts +112 -0
- package/dist/openapi.js +254 -0
- package/dist/permissions-module-DxCHuE-N.d.ts +31 -0
- package/dist/policy-BBp3Jq6G.js +226 -0
- package/dist/{policy-CnQj53Hq.d.ts → policy-DuZbwrKf.d.ts} +29 -1
- package/dist/policy.d.ts +2 -2
- package/dist/policy.js +1 -202
- package/dist/proxy.d.ts +46 -0
- package/dist/proxy.js +492 -0
- package/dist/testing.d.ts +44 -0
- package/dist/testing.js +176 -0
- package/dist/tools-BQE1O-7P.d.ts +271 -0
- package/dist/verifier-D6VIAYuT.js +152 -0
- package/dist/verifier-DF6gUMQ6.d.ts +84 -0
- package/package.json +35 -11
package/dist/testing.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { InMemoryTransport } from "@modelcontextprotocol/server";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
|
|
4
|
+
//#region src/permissions-module.ts
|
|
5
|
+
/**
|
|
6
|
+
* A permission map to start from, priced so it cannot be forgotten.
|
|
7
|
+
*
|
|
8
|
+
* Every capability gets a placeholder no role grants, which `reconcile` reports
|
|
9
|
+
* as unreachable and the boot refuses. The scaffold is deliberately useless
|
|
10
|
+
* until a person has decided what each capability costs — that decision is the
|
|
11
|
+
* whole point of the file, and a default would quietly make it for them.
|
|
12
|
+
*
|
|
13
|
+
* Returned as source rather than written, so the caller chooses where it lands.
|
|
14
|
+
*/
|
|
15
|
+
const UNASSIGNED = "TODO:unassigned";
|
|
16
|
+
function toPermissionsModule(record) {
|
|
17
|
+
return [
|
|
18
|
+
"// Generated from a recorded catalogue. Replace every TODO with a real permission.",
|
|
19
|
+
"",
|
|
20
|
+
"export const PERMISSIONS = {",
|
|
21
|
+
...record.names.map((label) => ` ${quote(label)}: ${literal(UNASSIGNED)},`),
|
|
22
|
+
"} as const;",
|
|
23
|
+
"",
|
|
24
|
+
"export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];",
|
|
25
|
+
...fingerprintLines(record),
|
|
26
|
+
...resourceUriLines(record.resourceUris ?? {}),
|
|
27
|
+
""
|
|
28
|
+
].join("\n");
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Only emitted when the recorder could produce digests. An OpenAPI document is
|
|
32
|
+
* already a file in the repository, diffed on the pull request by whoever
|
|
33
|
+
* changed it, so there is nothing for a second baseline to catch.
|
|
34
|
+
*/
|
|
35
|
+
function fingerprintLines(record) {
|
|
36
|
+
const fingerprints = record.fingerprints ?? {};
|
|
37
|
+
if (Object.keys(fingerprints).length === 0) return [];
|
|
38
|
+
return [
|
|
39
|
+
"",
|
|
40
|
+
"// What each capability looked like when this was recorded. A separate export",
|
|
41
|
+
"// because gate() takes the flat map above; this is the baseline CI compares.",
|
|
42
|
+
"export const FINGERPRINTS = {",
|
|
43
|
+
...record.names.map((label) => ` ${quote(label)}: ${literal(fingerprints[label] ?? "")},`),
|
|
44
|
+
"} as const;"
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Only emitted when the server has resources, so a tools-only map stays a map.
|
|
49
|
+
* `createMcpProxy` needs this to price a read, which names a URI and never a
|
|
50
|
+
* label; `gate()` never sees it.
|
|
51
|
+
*/
|
|
52
|
+
function resourceUriLines(resourceUris) {
|
|
53
|
+
const labels = Object.keys(resourceUris).sort();
|
|
54
|
+
if (labels.length === 0) return [];
|
|
55
|
+
return [
|
|
56
|
+
"",
|
|
57
|
+
"// Where each resource answers. createMcpProxy() matches a read against these,",
|
|
58
|
+
"// templates included, because a resources/read carries a URI and not a label.",
|
|
59
|
+
"export const RESOURCE_URIS = {",
|
|
60
|
+
...labels.map((label) => ` ${quote(label)}: ${literal(resourceUris[label] ?? "")},`),
|
|
61
|
+
"} as const;"
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Bare where it is a valid identifier, quoted where the label carries a prefix.
|
|
66
|
+
*
|
|
67
|
+
* `__proto__` gets neither. In an object literal it sets the prototype instead
|
|
68
|
+
* of creating a property — as an identifier *and* as a string key — so a
|
|
69
|
+
* capability by that name would silently vanish from the map that prices it.
|
|
70
|
+
* Only a computed key makes an own property.
|
|
71
|
+
*/
|
|
72
|
+
function quote(label) {
|
|
73
|
+
if (label === "__proto__") return `[${literal(label)}]`;
|
|
74
|
+
return /^[A-Za-z_$][\w$]*$/.test(label) ? label : literal(label);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* A string literal, escaped.
|
|
78
|
+
*
|
|
79
|
+
* Names and URIs come from the server being recorded, not from us. A quote or a
|
|
80
|
+
* backslash in either one would otherwise close the literal early and emit a
|
|
81
|
+
* module that does not parse — or, worse, one that parses into something else.
|
|
82
|
+
*/
|
|
83
|
+
function literal(value) {
|
|
84
|
+
return JSON.stringify(value);
|
|
85
|
+
}
|
|
86
|
+
//#endregion
|
|
87
|
+
//#region src/testing.ts
|
|
88
|
+
function digest(parts) {
|
|
89
|
+
return createHash("sha256").update(JSON.stringify(canonical(parts))).digest("hex").slice(0, 16);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Key order is an accident of how a value was built, so sort it away. Without
|
|
93
|
+
* this an SDK that emitted the same definition in a different order would churn
|
|
94
|
+
* every fingerprint in a snapshot and teach people to ignore the diff.
|
|
95
|
+
*/
|
|
96
|
+
function canonical(value) {
|
|
97
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
98
|
+
if (value !== null && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, inner]) => [key, canonical(inner)]));
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
async function recordCapabilities(factory) {
|
|
102
|
+
const server = await factory();
|
|
103
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
104
|
+
await server.connect(serverTransport);
|
|
105
|
+
const client = new Client({
|
|
106
|
+
name: "mcp-authz-record-capabilities",
|
|
107
|
+
version: "1.0.0"
|
|
108
|
+
});
|
|
109
|
+
await client.connect(clientTransport);
|
|
110
|
+
try {
|
|
111
|
+
return await listFrom(client);
|
|
112
|
+
} finally {
|
|
113
|
+
await client.close();
|
|
114
|
+
await server.close();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Record a server you can only reach by URL.
|
|
119
|
+
*
|
|
120
|
+
* The objection that rules out listing a *gated* server does not apply here: an
|
|
121
|
+
* upstream reached with a service credential answers with everything it has, so
|
|
122
|
+
* the map is complete. Pass `fetch` to drive a handler directly instead of a
|
|
123
|
+
* socket.
|
|
124
|
+
*/
|
|
125
|
+
async function recordUpstream(url, options = {}) {
|
|
126
|
+
const transport = new StreamableHTTPClientTransport(new URL(url), {
|
|
127
|
+
...options.fetch ? { fetch: options.fetch } : {},
|
|
128
|
+
...options.bearer ? { authProvider: { token: async () => options.bearer } } : {}
|
|
129
|
+
});
|
|
130
|
+
const client = new Client({
|
|
131
|
+
name: "mcp-authz-record-capabilities",
|
|
132
|
+
version: "1.0.0"
|
|
133
|
+
});
|
|
134
|
+
await client.connect(transport);
|
|
135
|
+
try {
|
|
136
|
+
return await listFrom(client);
|
|
137
|
+
} finally {
|
|
138
|
+
await client.close();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async function listFrom(client) {
|
|
142
|
+
{
|
|
143
|
+
const advertised = client.getServerCapabilities() ?? {};
|
|
144
|
+
const none = {
|
|
145
|
+
tools: [],
|
|
146
|
+
prompts: [],
|
|
147
|
+
resources: [],
|
|
148
|
+
resourceTemplates: []
|
|
149
|
+
};
|
|
150
|
+
const [tools, prompts, resources, templates] = await Promise.all([
|
|
151
|
+
advertised.tools ? client.listTools() : none,
|
|
152
|
+
advertised.prompts ? client.listPrompts() : none,
|
|
153
|
+
advertised.resources ? client.listResources() : none,
|
|
154
|
+
advertised.resources ? client.listResourceTemplates() : none
|
|
155
|
+
]);
|
|
156
|
+
const labelled = [
|
|
157
|
+
...tools.tools.map((tool) => [tool.name, tool]),
|
|
158
|
+
...prompts.prompts.map((prompt) => [`prompt:${prompt.name}`, prompt]),
|
|
159
|
+
...resources.resources.map((resource) => [`resource:${resource.name}`, resource]),
|
|
160
|
+
...templates.resourceTemplates.map((template) => [`resource:${template.name}`, template])
|
|
161
|
+
];
|
|
162
|
+
const resourceUris = Object.fromEntries([...resources.resources.map((resource) => [`resource:${resource.name}`, resource.uri]), ...templates.resourceTemplates.map((template) => [`resource:${template.name}`, template.uriTemplate])]);
|
|
163
|
+
const names = labelled.map(([label]) => label).sort();
|
|
164
|
+
const byLabel = new Map(labelled);
|
|
165
|
+
return {
|
|
166
|
+
names,
|
|
167
|
+
fingerprints: Object.fromEntries(names.map((label) => [label, digest({
|
|
168
|
+
label,
|
|
169
|
+
definition: byLabel.get(label)
|
|
170
|
+
})])),
|
|
171
|
+
resourceUris
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
//#endregion
|
|
176
|
+
export { UNASSIGNED, recordCapabilities, recordUpstream, toPermissionsModule };
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { a as PermissionCatalog, l as Principal, s as Policy } from "./policy-DuZbwrKf.js";
|
|
2
|
+
import { CallToolResult, GetPromptResult, Icon, Implementation, McpServer, ReadResourceResult, ResourceMetadata, ResourceTemplate, ServerOptions, StandardSchemaV1, StandardSchemaWithJSON, ToolAnnotations } from "@modelcontextprotocol/server";
|
|
3
|
+
//#region src/tools.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Tools, prompts and resources that carry the permission they require.
|
|
6
|
+
*
|
|
7
|
+
* Declaring it here rather than in a separate rules file is the whole point: it
|
|
8
|
+
* is checked against the policy by the compiler, reconciled against the policy
|
|
9
|
+
* at boot, and it cannot drift when somebody renames the tool.
|
|
10
|
+
*
|
|
11
|
+
* MCP exposes three things a caller can reach, so gating only tools leaves two
|
|
12
|
+
* doors open. All three register the same way: check the permission, skip the
|
|
13
|
+
* registration when the caller lacks it, audit what they do reach.
|
|
14
|
+
*/
|
|
15
|
+
/** Which of the three a caller reached. */
|
|
16
|
+
type Capability = 'tool' | 'prompt' | 'resource';
|
|
17
|
+
/** What actually happened, for the audit log the downstream API cannot write. */
|
|
18
|
+
/**
|
|
19
|
+
* What happened, and who it was.
|
|
20
|
+
*
|
|
21
|
+
* `issuer` and `sub` are required because every path that reaches here is
|
|
22
|
+
* downstream of a verified bearer token — they come from a `Principal`, which
|
|
23
|
+
* cannot exist without one. That is what makes this an audit trail rather than
|
|
24
|
+
* a log of claims somebody sent.
|
|
25
|
+
*
|
|
26
|
+
* If a mode ever forwards traffic without verifying identity, **it must omit
|
|
27
|
+
* identity from its events rather than fill these in from decoded token
|
|
28
|
+
* claims.** Decoded claims are attacker-controlled, and putting them in a field
|
|
29
|
+
* named `sub` presents low-integrity data in a high-trust schema: the failure
|
|
30
|
+
* mode is that it reads exactly like evidence. Give that mode its own event
|
|
31
|
+
* type, tagged with whether the identity was verified, rather than widening
|
|
32
|
+
* this one.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* What an audit record can be about.
|
|
36
|
+
*
|
|
37
|
+
* Wider than `Capability` because `mcp-authz/openapi` gates HTTP operations
|
|
38
|
+
* through the same events, and an auditor reading one store wants one shape.
|
|
39
|
+
* The definition types stay narrow: an MCP tool cannot declare itself an
|
|
40
|
+
* `operation`, because there is no such thing to declare.
|
|
41
|
+
*/
|
|
42
|
+
type AuditedKind = Capability | 'operation';
|
|
43
|
+
type AuditEvent = {
|
|
44
|
+
/**
|
|
45
|
+
* What this record is, and which shape it is in.
|
|
46
|
+
*
|
|
47
|
+
* These events leave the process for somebody's log store and are kept for
|
|
48
|
+
* years, next to `mcp_authz.decision.v1` records that share half their
|
|
49
|
+
* fields. A query that tells them apart by guessing which fields are present
|
|
50
|
+
* breaks the first time either one grows a field.
|
|
51
|
+
*
|
|
52
|
+
* A new optional field does not move the `v1`. Changing what an existing
|
|
53
|
+
* field means does, because a stored query cannot tell that apart.
|
|
54
|
+
*/
|
|
55
|
+
type: 'mcp_authz.audit.v1';
|
|
56
|
+
/**
|
|
57
|
+
* The two events of one call, under one id.
|
|
58
|
+
*
|
|
59
|
+
* `attempt` and its terminal event are separate rows in whatever store they
|
|
60
|
+
* land in, and correlating them by identity and timestamp breaks under
|
|
61
|
+
* exactly the concurrency that makes the question worth asking.
|
|
62
|
+
*/
|
|
63
|
+
callId: string;
|
|
64
|
+
issuer: string;
|
|
65
|
+
sub: string;
|
|
66
|
+
email?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Verified Workspace domain, when the AS passes `hd` through.
|
|
69
|
+
*
|
|
70
|
+
* The nearest thing to an organisation this library can prove. Key one by
|
|
71
|
+
* `(issuer, domain)`: an issuer alone is right when each customer brings its
|
|
72
|
+
* own authorization server, and wrong when one server serves them all.
|
|
73
|
+
*/
|
|
74
|
+
domain?: string;
|
|
75
|
+
/** Which deployment emitted this, when you named it. */
|
|
76
|
+
emitter?: string;
|
|
77
|
+
kind: AuditedKind;
|
|
78
|
+
/** The registered name, e.g. `update_case`. */
|
|
79
|
+
name: string;
|
|
80
|
+
permission: string;
|
|
81
|
+
/** Whatever the definition's own `audit` said the call touched, e.g. `case:C1234`. */
|
|
82
|
+
resource?: string;
|
|
83
|
+
decision: 'allow' | 'deny';
|
|
84
|
+
phase: 'attempt' | 'success' | 'failure' | 'refused';
|
|
85
|
+
/** Who approved it, when the capability asked for a second person. */
|
|
86
|
+
approvedBy?: string;
|
|
87
|
+
at: string;
|
|
88
|
+
durationMs?: number;
|
|
89
|
+
error?: string;
|
|
90
|
+
};
|
|
91
|
+
type AuditSink = (event: AuditEvent) => unknown | Promise<unknown>;
|
|
92
|
+
type AuditDeliveryFailure = {
|
|
93
|
+
error: unknown;
|
|
94
|
+
event: AuditEvent;
|
|
95
|
+
};
|
|
96
|
+
type AuditErrorSink = (failure: AuditDeliveryFailure) => unknown | Promise<unknown>;
|
|
97
|
+
/**
|
|
98
|
+
* What a human is being asked to approve, before it happens.
|
|
99
|
+
*
|
|
100
|
+
* Everything here was proved rather than claimed: the identity came off a
|
|
101
|
+
* verified token and the permission was already checked, so the question left
|
|
102
|
+
* for a person is only whether this particular call should happen.
|
|
103
|
+
*/
|
|
104
|
+
type ApprovalRequest<A = unknown> = {
|
|
105
|
+
issuer: string;
|
|
106
|
+
sub: string;
|
|
107
|
+
email?: string;
|
|
108
|
+
kind: Capability;
|
|
109
|
+
name: string;
|
|
110
|
+
permission: string;
|
|
111
|
+
/** The arguments the call would run with, so the message can name them. */
|
|
112
|
+
arguments: A;
|
|
113
|
+
/** Whatever the definition's own `audit` said the call touches. */
|
|
114
|
+
resource?: string;
|
|
115
|
+
at: string;
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* Approving anonymously is not a second pair of eyes, so `by` is required on
|
|
119
|
+
* the approving branch and the compiler will not let you omit it.
|
|
120
|
+
*/
|
|
121
|
+
type ApprovalDecision = {
|
|
122
|
+
approved: true;
|
|
123
|
+
by: string;
|
|
124
|
+
reason?: string;
|
|
125
|
+
} | {
|
|
126
|
+
approved: false;
|
|
127
|
+
by?: string;
|
|
128
|
+
reason?: string;
|
|
129
|
+
};
|
|
130
|
+
type ApprovalSink = (request: ApprovalRequest) => ApprovalDecision | Promise<ApprovalDecision>;
|
|
131
|
+
/** Thrown into the handler's place when a person said no, or said nothing in time. */
|
|
132
|
+
declare class ApprovalRefusedError extends Error {
|
|
133
|
+
readonly capability: string;
|
|
134
|
+
readonly by?: string;
|
|
135
|
+
constructor(capability: string, reason: string, by?: string);
|
|
136
|
+
}
|
|
137
|
+
type InferArgs<S> = S extends StandardSchemaV1<unknown, infer Output> ? Output : undefined;
|
|
138
|
+
/** Shared by all three: what it costs, and what the call touched. */
|
|
139
|
+
type Gated<P extends string, A> = {
|
|
140
|
+
/** Required to reach it. Typed to the policy's permissions, so a typo is a build error. */
|
|
141
|
+
permission: P;
|
|
142
|
+
/**
|
|
143
|
+
* Name the thing the call touched, for the audit event. The library cannot
|
|
144
|
+
* know that `{ caseId: 'C1234' }` means a case; the application always does.
|
|
145
|
+
*/
|
|
146
|
+
audit?: (args: A) => string | undefined;
|
|
147
|
+
/**
|
|
148
|
+
* Ask a person before this runs. `true` for every call, or a predicate when
|
|
149
|
+
* only some arguments warrant it — a refund over a threshold, a delete that
|
|
150
|
+
* names production.
|
|
151
|
+
*
|
|
152
|
+
* This is not the permission check repeated. The caller already holds the
|
|
153
|
+
* permission; approval is for actions that want a second person anyway.
|
|
154
|
+
*/
|
|
155
|
+
approval?: boolean | ((args: A) => boolean);
|
|
156
|
+
};
|
|
157
|
+
type ToolConfig<P extends string, S> = Gated<P, InferArgs<S>> & {
|
|
158
|
+
title?: string;
|
|
159
|
+
description?: string;
|
|
160
|
+
inputSchema?: S;
|
|
161
|
+
outputSchema?: StandardSchemaWithJSON;
|
|
162
|
+
annotations?: ToolAnnotations;
|
|
163
|
+
icons?: Icon[];
|
|
164
|
+
};
|
|
165
|
+
type PromptConfig<P extends string, S> = Gated<P, InferArgs<S>> & {
|
|
166
|
+
title?: string;
|
|
167
|
+
description?: string;
|
|
168
|
+
argsSchema?: S;
|
|
169
|
+
icons?: Icon[];
|
|
170
|
+
};
|
|
171
|
+
type ResourceConfig<P extends string> = Gated<P, URL> & ResourceMetadata & {
|
|
172
|
+
/** The URI clients read, or a template for a family of them. */
|
|
173
|
+
uri: string | ResourceTemplate;
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
176
|
+
* Erased once built, so a server registers every capability the same way
|
|
177
|
+
* whatever it accepts.
|
|
178
|
+
*/
|
|
179
|
+
type Definition<P extends string, C = Principal<P>> = {
|
|
180
|
+
/**
|
|
181
|
+
* Unique key for the boot-time check. Bare for a tool, prefixed otherwise, so
|
|
182
|
+
* a prompt sharing a tool's name stays a separate entry rather than shadowing
|
|
183
|
+
* it.
|
|
184
|
+
*/
|
|
185
|
+
label: string;
|
|
186
|
+
kind: Capability;
|
|
187
|
+
/** Exact protocol name used to refuse an unpermitted direct invocation before scope step-up. */
|
|
188
|
+
routeName?: string;
|
|
189
|
+
routeMatches?: (name: string) => boolean;
|
|
190
|
+
permission: P;
|
|
191
|
+
/** Whether this definition can ask for a person, so boot can check a sink exists. */
|
|
192
|
+
approval?: boolean;
|
|
193
|
+
register: (server: McpServer, principal: Principal<P>, context: C, guards: Guards<unknown>) => void;
|
|
194
|
+
};
|
|
195
|
+
/** What every registration needs to record and, sometimes, to ask. */
|
|
196
|
+
type Guards<A> = {
|
|
197
|
+
onAudit?: AuditSink;
|
|
198
|
+
onAuditError?: AuditErrorSink;
|
|
199
|
+
/** Names this deployment on every event it emits. */
|
|
200
|
+
emitter?: string;
|
|
201
|
+
onApproval?: ApprovalSink;
|
|
202
|
+
approvalTimeoutMs: number;
|
|
203
|
+
/** Present only when this capability declared one. */
|
|
204
|
+
needsApproval?: (args: A) => boolean;
|
|
205
|
+
};
|
|
206
|
+
type ServerOptions$1 = Implementation & {
|
|
207
|
+
/** Called on every permitted invocation. The one record tying a person to an action. */
|
|
208
|
+
onAudit?: AuditSink;
|
|
209
|
+
/**
|
|
210
|
+
* Names this deployment on every event it emits.
|
|
211
|
+
*
|
|
212
|
+
* Set the same value in every entry point of one deployment — a dashboard
|
|
213
|
+
* reading several of them cannot otherwise tell which server a call reached.
|
|
214
|
+
* Left unset, the field is absent rather than guessed.
|
|
215
|
+
*/
|
|
216
|
+
emitter?: string;
|
|
217
|
+
/** Receives a failed terminal audit write without changing the completed action's result. */
|
|
218
|
+
onAuditError?: AuditErrorSink;
|
|
219
|
+
/**
|
|
220
|
+
* Ask a person. Awaited while the caller's request stays open, so this holds
|
|
221
|
+
* a connection for as long as it takes to answer — required as soon as any
|
|
222
|
+
* capability declares `approval`, and refused at boot when one does and this
|
|
223
|
+
* is missing.
|
|
224
|
+
*/
|
|
225
|
+
onApproval?: ApprovalSink;
|
|
226
|
+
/**
|
|
227
|
+
* How long to wait before treating silence as a refusal. Defaults to 45s,
|
|
228
|
+
* which is under the 60s idle timeout most proxies ship with. Raise it only
|
|
229
|
+
* as far as whatever sits in front of this server will actually hold.
|
|
230
|
+
*/
|
|
231
|
+
approvalTimeoutMs?: number;
|
|
232
|
+
/** Pass-through SDK options; declared gated capabilities are merged in. */
|
|
233
|
+
mcp?: ServerOptions;
|
|
234
|
+
};
|
|
235
|
+
/**
|
|
236
|
+
* A per-request server factory over a fixed set of capabilities, plus the
|
|
237
|
+
* label-to-permission map that `createMcpFetch` reconciles against the policy
|
|
238
|
+
* at boot.
|
|
239
|
+
*/
|
|
240
|
+
type ServerFactory<C> = ((context: C) => McpServer) & {
|
|
241
|
+
permissions: ReadonlyMap<string, string>;
|
|
242
|
+
routePermissions: ReadonlyMap<string, string>;
|
|
243
|
+
permissionForRoute: (kind: Capability, name: string) => string | undefined;
|
|
244
|
+
/** The registered route name a concrete request name resolves to, template included. */
|
|
245
|
+
routeNameFor: (kind: Capability, name: string) => string | undefined;
|
|
246
|
+
};
|
|
247
|
+
/**
|
|
248
|
+
* Everything bound to one policy: `permission` accepts only what that policy can
|
|
249
|
+
* grant, in all four places, from one call.
|
|
250
|
+
*
|
|
251
|
+
* The policy is a type carrier here and is never invoked. Authorization happens
|
|
252
|
+
* once per request when the principal is resolved, not once per definition.
|
|
253
|
+
*
|
|
254
|
+
* ```ts
|
|
255
|
+
* const { tool, prompt, resource, server } = authz(policy);
|
|
256
|
+
* ```
|
|
257
|
+
*/
|
|
258
|
+
declare function bindAuthz<P extends string, C, H>(_permissions: Policy<P> | PermissionCatalog<P>, principalOf: (context: C) => Principal<P>, handlerContext: (context: C, principal: Principal<P>) => H): {
|
|
259
|
+
tool: <S extends StandardSchemaWithJSON | undefined = undefined>(name: string, config: ToolConfig<P, S>, handler: (args: InferArgs<S>, context: H) => CallToolResult | Promise<CallToolResult>) => Definition<P, C>;
|
|
260
|
+
prompt: <S extends StandardSchemaWithJSON | undefined = undefined>(name: string, config: PromptConfig<P, S>, handler: (args: InferArgs<S>, context: H) => GetPromptResult | Promise<GetPromptResult>) => Definition<P, C>;
|
|
261
|
+
resource: (name: string, config: ResourceConfig<P>, handler: (uri: URL, context: H) => ReadResourceResult | Promise<ReadResourceResult>) => Definition<P, C>;
|
|
262
|
+
server: (definitions: readonly Definition<P, C>[], options: ServerOptions$1) => ServerFactory<C>;
|
|
263
|
+
};
|
|
264
|
+
declare function authz<P extends string>(policy: Policy<P> | PermissionCatalog<P>): ReturnType<typeof bindAuthz<P, Principal<P>, {
|
|
265
|
+
principal: Principal<P>;
|
|
266
|
+
}>>;
|
|
267
|
+
declare function authz<P extends string, C>(policy: Policy<P> | PermissionCatalog<P>, options: {
|
|
268
|
+
principal: (context: C) => Principal<P>;
|
|
269
|
+
}): ReturnType<typeof bindAuthz<P, C, C>>;
|
|
270
|
+
//#endregion
|
|
271
|
+
export { AuditDeliveryFailure as a, AuditSink as c, PromptConfig as d, ResourceConfig as f, authz as h, ApprovalSink as i, Capability as l, ToolConfig as m, ApprovalRefusedError as n, AuditErrorSink as o, ServerOptions$1 as p, ApprovalRequest as r, AuditEvent as s, ApprovalDecision as t, Definition as u };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { OAuthError, OAuthErrorCode } from "@modelcontextprotocol/server";
|
|
2
|
+
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
3
|
+
//#region src/identity.ts
|
|
4
|
+
var AccessDeniedError = class AccessDeniedError extends Error {
|
|
5
|
+
email;
|
|
6
|
+
reason;
|
|
7
|
+
constructor(email, reason, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "AccessDeniedError";
|
|
10
|
+
this.email = email;
|
|
11
|
+
this.reason = reason;
|
|
12
|
+
}
|
|
13
|
+
static notPermitted(email, policyHint = "the access policy") {
|
|
14
|
+
return new AccessDeniedError(email, "not_permitted", `${email} matches no rule in ${policyHint}, so they hold no permissions. Ask an administrator to grant them a role.`);
|
|
15
|
+
}
|
|
16
|
+
static noCredential(email, credentialHint = "a backend credential") {
|
|
17
|
+
return new AccessDeniedError(email, "no_credential", `${email} is permitted but has no ${credentialHint}, and no shared account is configured.`);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/decision.ts
|
|
22
|
+
function policyDenied(error) {
|
|
23
|
+
return Response.json({
|
|
24
|
+
error: "forbidden",
|
|
25
|
+
reason: "policy_denied",
|
|
26
|
+
error_description: error.message
|
|
27
|
+
}, { status: 403 });
|
|
28
|
+
}
|
|
29
|
+
async function emitDecision(sink, principal, decision, reason, emitter) {
|
|
30
|
+
if (!principal) return;
|
|
31
|
+
await sink?.({
|
|
32
|
+
type: "mcp_authz.decision.v1",
|
|
33
|
+
issuer: principal.issuer,
|
|
34
|
+
sub: principal.sub,
|
|
35
|
+
email: principal.email,
|
|
36
|
+
domain: principal.domain,
|
|
37
|
+
...emitter ? { emitter } : {},
|
|
38
|
+
decision,
|
|
39
|
+
roles: principal.roles,
|
|
40
|
+
permissions: principal.permissions,
|
|
41
|
+
...reason ? { reason } : {},
|
|
42
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
function principalLabel(principal) {
|
|
46
|
+
return principal.email ?? `${principal.issuer}#${principal.sub}`;
|
|
47
|
+
}
|
|
48
|
+
//#endregion
|
|
49
|
+
//#region src/verifier.ts
|
|
50
|
+
const invalidToken = (message) => new OAuthError(OAuthErrorCode.InvalidToken, message);
|
|
51
|
+
/**
|
|
52
|
+
* A JWKS-backed verifier. Keys are fetched once and cached by `jose`, which
|
|
53
|
+
* also handles rotation, so a key roll at the AS does not need a redeploy.
|
|
54
|
+
*/
|
|
55
|
+
function jwksVerifier(options) {
|
|
56
|
+
const jwks = createRemoteJWKSet(new URL(options.jwksUri));
|
|
57
|
+
const emailClaim = options.emailClaim ?? "email";
|
|
58
|
+
const emailVerifiedClaim = options.emailVerifiedClaim ?? "email_verified";
|
|
59
|
+
const expectedResource = new URL(options.resource.href).href.split("#")[0];
|
|
60
|
+
return {
|
|
61
|
+
async verifyAccessToken(token) {
|
|
62
|
+
let payload;
|
|
63
|
+
try {
|
|
64
|
+
payload = (await jwtVerify(token, jwks, {
|
|
65
|
+
issuer: options.issuer,
|
|
66
|
+
audience: expectedResource
|
|
67
|
+
})).payload;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw invalidToken(`Token rejected: ${error instanceof Error ? error.message : String(error)}`);
|
|
70
|
+
}
|
|
71
|
+
if (typeof payload.exp !== "number") throw invalidToken("Token has no `exp` claim.");
|
|
72
|
+
const claimed = payload[emailClaim];
|
|
73
|
+
const email = typeof claimed === "string" && claimed ? claimed : void 0;
|
|
74
|
+
if (!email && (options.requireEmail ?? true)) throw invalidToken(`Token carries no '${emailClaim}' claim, so there is no identity to map.`);
|
|
75
|
+
if (email && (options.requireEmailVerified ?? true) && payload[emailVerifiedClaim] !== true) throw invalidToken(`Token does not prove '${emailClaim}' with '${emailVerifiedClaim}: true'.`);
|
|
76
|
+
const sub = payload.sub;
|
|
77
|
+
if (typeof sub !== "string" || !sub) throw invalidToken("Token has no `sub` claim, so there is no stable subject to bind to.");
|
|
78
|
+
const domain = typeof payload.hd === "string" ? payload.hd : void 0;
|
|
79
|
+
if (options.allowedDomain && domain?.toLowerCase() !== options.allowedDomain.toLowerCase()) throw invalidToken(`Token is for ${domain ?? "an unknown domain"}, not ${options.allowedDomain}.`);
|
|
80
|
+
return {
|
|
81
|
+
token,
|
|
82
|
+
clientId: typeof payload.client_id === "string" ? payload.client_id : typeof payload.azp === "string" ? payload.azp : sub,
|
|
83
|
+
scopes: scopesOf(payload.scope),
|
|
84
|
+
expiresAt: payload.exp,
|
|
85
|
+
resource: options.resource,
|
|
86
|
+
extra: {
|
|
87
|
+
issuer: options.issuer,
|
|
88
|
+
sub,
|
|
89
|
+
email,
|
|
90
|
+
emailVerified: email !== void 0 && (options.requireEmailVerified === false || payload[emailVerifiedClaim] === true),
|
|
91
|
+
domain,
|
|
92
|
+
claims: payload
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
identityOf: identityFromAuth
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The verifier a resource server ends up with, however it was configured.
|
|
101
|
+
*
|
|
102
|
+
* Every entry point here faces the same three choices — bring your own
|
|
103
|
+
* verifier, configure the built-in one, or say nothing and let discovery fill
|
|
104
|
+
* it in — and has to refuse the same contradiction between the first two. Doing
|
|
105
|
+
* that in one place is what stops two entry points disagreeing about which
|
|
106
|
+
* issuer a token is checked against, or which audience it must carry.
|
|
107
|
+
*/
|
|
108
|
+
function verifierFor(options) {
|
|
109
|
+
if (options.tokenVerifier && options.verifier) throw new Error("Pass either `tokenVerifier` or built-in `verifier` options, not both.");
|
|
110
|
+
if (options.tokenVerifier) return {
|
|
111
|
+
tokenVerifier: options.tokenVerifier,
|
|
112
|
+
mapIdentity: options.identityFromAuth ?? identityFromAuth
|
|
113
|
+
};
|
|
114
|
+
const published = typeof options.oauthMetadata.jwks_uri === "string" ? options.oauthMetadata.jwks_uri : void 0;
|
|
115
|
+
const jwksUri = options.verifier?.jwksUri ?? published;
|
|
116
|
+
if (!jwksUri) throw new Error("No JWKS to verify tokens against. Set `verifier.jwksUri`, use a custom `tokenVerifier`, or use `discoverOAuth(issuer)`, whose metadata carries `jwks_uri`.");
|
|
117
|
+
const builtIn = jwksVerifier({
|
|
118
|
+
...options.verifier,
|
|
119
|
+
jwksUri,
|
|
120
|
+
issuer: options.verifier?.issuer ?? options.oauthMetadata.issuer,
|
|
121
|
+
resource: options.verifier?.resource ?? options.resourceServerUrl
|
|
122
|
+
});
|
|
123
|
+
return {
|
|
124
|
+
tokenVerifier: builtIn,
|
|
125
|
+
mapIdentity: options.identityFromAuth ?? builtIn.identityOf
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** Default identity mapper for custom verifiers using `AuthInfo.extra`. */
|
|
129
|
+
function identityFromAuth(auth) {
|
|
130
|
+
const { issuer, sub, email, emailVerified, domain, claims } = auth.extra ?? {};
|
|
131
|
+
if (typeof issuer !== "string" || !issuer || typeof sub !== "string" || !sub) throw invalidToken("Verified token carried no issuer or subject.");
|
|
132
|
+
if (email !== void 0 && (typeof email !== "string" || !email)) throw invalidToken("Verified token carried an invalid email.");
|
|
133
|
+
return {
|
|
134
|
+
issuer,
|
|
135
|
+
sub,
|
|
136
|
+
email: typeof email === "string" ? email : void 0,
|
|
137
|
+
emailVerified: emailVerified === true,
|
|
138
|
+
domain: typeof domain === "string" ? domain : void 0,
|
|
139
|
+
claims: isRecord(claims) ? claims : {}
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
function isRecord(value) {
|
|
143
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
144
|
+
}
|
|
145
|
+
/** OAuth scope is a space-delimited string; some servers send an array anyway. */
|
|
146
|
+
function scopesOf(scope) {
|
|
147
|
+
if (Array.isArray(scope)) return scope.filter((s) => typeof s === "string");
|
|
148
|
+
if (typeof scope === "string") return scope.split(" ").filter(Boolean);
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
//#endregion
|
|
152
|
+
export { policyDenied as a, emitDecision as i, jwksVerifier as n, principalLabel as o, verifierFor as r, AccessDeniedError as s, identityFromAuth as t };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { _ as Identity } from "./policy-DuZbwrKf.js";
|
|
2
|
+
import { AuthInfo, OAuthMetadata, OAuthTokenVerifier } from "@modelcontextprotocol/server";
|
|
3
|
+
//#region src/decision.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* The access decision and how it is reported, with nothing protocol-shaped
|
|
6
|
+
* attached.
|
|
7
|
+
*
|
|
8
|
+
* Its own module for the bundle rather than for tidiness: this rides in every
|
|
9
|
+
* enforcement path, while the rest of the ladder is MCP route classification
|
|
10
|
+
* and scope step-up. Left there, an OpenAPI deployment shipped the MCP wire
|
|
11
|
+
* format it never calls.
|
|
12
|
+
*/
|
|
13
|
+
type AuthorizationDecisionEvent = {
|
|
14
|
+
/** See `AuditEvent['type']`: same contract, its own shape and version. */
|
|
15
|
+
type: 'mcp_authz.decision.v1';
|
|
16
|
+
issuer: string;
|
|
17
|
+
sub: string;
|
|
18
|
+
email?: string;
|
|
19
|
+
/** See `AuditEvent['domain']`: the nearest thing to an organisation. */
|
|
20
|
+
domain?: string;
|
|
21
|
+
/** Which deployment emitted this, when you named it. */
|
|
22
|
+
emitter?: string;
|
|
23
|
+
decision: 'allow' | 'deny';
|
|
24
|
+
roles: readonly string[];
|
|
25
|
+
permissions: readonly string[];
|
|
26
|
+
reason?: string;
|
|
27
|
+
at: string;
|
|
28
|
+
};
|
|
29
|
+
type AuthorizationDecisionSink = (event: AuthorizationDecisionEvent) => unknown | Promise<unknown>;
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region src/verifier.d.ts
|
|
32
|
+
/**
|
|
33
|
+
* Token verification, the one thing the SDK deliberately leaves to you.
|
|
34
|
+
*
|
|
35
|
+
* `authInfo` is strictly pass-through in the SDK: it is never derived from
|
|
36
|
+
* request headers, and no token is checked unless we check it. So this file is
|
|
37
|
+
* the security boundary of the whole server.
|
|
38
|
+
*
|
|
39
|
+
* The authorization server is something that already exists (WorkOS, Stytch,
|
|
40
|
+
* Auth0) doing dynamic client registration / CIMD and Google Workspace login.
|
|
41
|
+
* Google cannot play that role itself: it has no DCR/CIMD, and it will not
|
|
42
|
+
* mint a token whose audience is this server.
|
|
43
|
+
*/
|
|
44
|
+
type VerifierOptions = {
|
|
45
|
+
/** The AS issuer, e.g. `https://auth.acme.com`. Must match the token's `iss`. */
|
|
46
|
+
issuer: string;
|
|
47
|
+
/** Where the AS publishes its signing keys. */
|
|
48
|
+
jwksUri: string;
|
|
49
|
+
/**
|
|
50
|
+
* This server's public URL. A token minted for a different resource is
|
|
51
|
+
* refused even when its signature is valid (RFC 8707).
|
|
52
|
+
*/
|
|
53
|
+
resource: URL;
|
|
54
|
+
/**
|
|
55
|
+
* Restrict to one Google Workspace domain, checked against the `hd` claim
|
|
56
|
+
* the AS passes through. Omit to accept any domain the AS admits.
|
|
57
|
+
*/
|
|
58
|
+
allowedDomain?: string;
|
|
59
|
+
/** Claim carrying the verified email. Auth0 and WorkOS both use `email`. */
|
|
60
|
+
emailClaim?: string;
|
|
61
|
+
/** Claim proving the email was verified. Defaults to `email_verified`. */
|
|
62
|
+
emailVerifiedClaim?: string;
|
|
63
|
+
/** Require an explicit `true` verified-email claim. Defaults to true. */
|
|
64
|
+
requireEmailVerified?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Require an email in every token. Defaults to true. Set false to accept an
|
|
67
|
+
* agent that signs in as itself, such as an OAuth client-credentials token:
|
|
68
|
+
* its identity is its `sub`, which a policy rule names. Email and domain
|
|
69
|
+
* rules never match it, and neither does `allowedDomain`; a rule with no
|
|
70
|
+
* `match` does. A token that does carry an email is checked as before.
|
|
71
|
+
*/
|
|
72
|
+
requireEmail?: boolean;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* A JWKS-backed verifier. Keys are fetched once and cached by `jose`, which
|
|
76
|
+
* also handles rotation, so a key roll at the AS does not need a redeploy.
|
|
77
|
+
*/
|
|
78
|
+
declare function jwksVerifier(options: VerifierOptions): OAuthTokenVerifier & {
|
|
79
|
+
identityOf: (auth: AuthInfo) => Identity;
|
|
80
|
+
};
|
|
81
|
+
/** Default identity mapper for custom verifiers using `AuthInfo.extra`. */
|
|
82
|
+
declare function identityFromAuth(auth: AuthInfo): Identity;
|
|
83
|
+
//#endregion
|
|
84
|
+
export { AuthorizationDecisionSink as a, AuthorizationDecisionEvent as i, identityFromAuth as n, jwksVerifier as r, VerifierOptions as t };
|