@lanes-sh/link 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/LICENSE +202 -0
- package/README.md +144 -0
- package/bin/lanes +42 -0
- package/instructions/agents/lanes-link-scout.md +73 -0
- package/instructions/skills/lanes-link/SKILL.md +187 -0
- package/package.json +95 -0
- package/src/audit/chain.ts +278 -0
- package/src/audit/conformance.ts +374 -0
- package/src/audit/fanout.ts +97 -0
- package/src/audit/index.ts +218 -0
- package/src/audit/stdout.ts +60 -0
- package/src/auth/index.ts +220 -0
- package/src/auth/oauth/metadata.ts +75 -0
- package/src/auth/oauth/server.ts +385 -0
- package/src/auth/oauth/store.ts +215 -0
- package/src/auth/oidc.ts +206 -0
- package/src/auth/remote.ts +72 -0
- package/src/cli/argv.ts +94 -0
- package/src/cli/callback-page.ts +256 -0
- package/src/cli/commands/connect/accounts.ts +94 -0
- package/src/cli/commands/connect/authorise.ts +298 -0
- package/src/cli/commands/connect/client.ts +284 -0
- package/src/cli/commands/connect/index.ts +398 -0
- package/src/cli/commands/connect/outcome.ts +119 -0
- package/src/cli/commands/connect/requirements.ts +103 -0
- package/src/cli/commands/connect/scopes-gate.ts +146 -0
- package/src/cli/commands/connect/settle.ts +136 -0
- package/src/cli/commands/connect/setup.ts +276 -0
- package/src/cli/commands/mcp/assets.ts +189 -0
- package/src/cli/commands/mcp/harnesses.ts +143 -0
- package/src/cli/commands/mcp/list.ts +70 -0
- package/src/cli/commands/mcp/register.ts +199 -0
- package/src/cli/commands/mcp/stdio.ts +57 -0
- package/src/cli/commands/mcp.ts +22 -0
- package/src/cli/commands/operate/attach.ts +121 -0
- package/src/cli/commands/operate/audit.ts +119 -0
- package/src/cli/commands/operate/inspect.ts +355 -0
- package/src/cli/commands/operate/outputs.ts +206 -0
- package/src/cli/commands/operate/policy.ts +80 -0
- package/src/cli/commands/operate/serve.ts +55 -0
- package/src/cli/commands/operate/status.ts +133 -0
- package/src/cli/commands/operate/token.ts +70 -0
- package/src/cli/commands/operate.ts +27 -0
- package/src/cli/commands/owner/memory.ts +110 -0
- package/src/cli/commands/owner/shared.ts +125 -0
- package/src/cli/commands/owner/skills.ts +92 -0
- package/src/cli/commands/owner/vault.ts +140 -0
- package/src/cli/commands/owner.ts +46 -0
- package/src/cli/commands/profile/removal.ts +278 -0
- package/src/cli/commands/profile/remove.ts +315 -0
- package/src/cli/commands/profile.ts +156 -0
- package/src/cli/commands/secrets.ts +176 -0
- package/src/cli/commands/setup.ts +150 -0
- package/src/cli/commands/target.ts +310 -0
- package/src/cli/config-edit.ts +397 -0
- package/src/cli/endpoint-url.ts +77 -0
- package/src/cli/identity.ts +109 -0
- package/src/cli/lanes.ts +78 -0
- package/src/cli/main.ts +333 -0
- package/src/cli/oauth-error.ts +13 -0
- package/src/cli/oauth-exchange.ts +146 -0
- package/src/cli/oauth.ts +354 -0
- package/src/cli/output.ts +184 -0
- package/src/cli/prompt.ts +180 -0
- package/src/cli/publish.ts +185 -0
- package/src/cli/runtime/discovery.ts +123 -0
- package/src/cli/runtime/open.ts +342 -0
- package/src/cli/runtime/registry.ts +185 -0
- package/src/cli/runtime/select.ts +124 -0
- package/src/cli/runtime.ts +34 -0
- package/src/cli/scopes.ts +63 -0
- package/src/cli/usage.ts +111 -0
- package/src/cli/version.ts +25 -0
- package/src/connectivity/auth/README.md +36 -0
- package/src/connectivity/auth/api-key/index.ts +43 -0
- package/src/connectivity/auth/authorize.ts +49 -0
- package/src/connectivity/auth/basic/index.ts +68 -0
- package/src/connectivity/auth/bearer/index.ts +13 -0
- package/src/connectivity/auth/credential.ts +19 -0
- package/src/connectivity/auth/header/index.ts +18 -0
- package/src/connectivity/auth/index.ts +35 -0
- package/src/connectivity/auth/none/index.ts +12 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +261 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +64 -0
- package/src/connectivity/auth/oauth-authcode/provider.ts +279 -0
- package/src/connectivity/auth/oauth-authcode/refresh.ts +118 -0
- package/src/connectivity/auth/resolve.ts +61 -0
- package/src/connectivity/auth/strategy/index.ts +14 -0
- package/src/connectivity/capability.ts +164 -0
- package/src/connectivity/connector.ts +175 -0
- package/src/connectivity/context.ts +77 -0
- package/src/connectivity/index.ts +94 -0
- package/src/connectivity/mail/attachments.ts +368 -0
- package/src/connectivity/mail/compose.ts +73 -0
- package/src/connectivity/mail/index.ts +40 -0
- package/src/connectivity/mail/message.ts +82 -0
- package/src/connectivity/mail/nodemailer.d.ts +83 -0
- package/src/connectivity/mail/staging.ts +143 -0
- package/src/connectivity/mail/url.ts +301 -0
- package/src/connectivity/manifest/auth.ts +135 -0
- package/src/connectivity/manifest/bundles.ts +23 -0
- package/src/connectivity/manifest/connector.ts +168 -0
- package/src/connectivity/manifest/credential-ref.ts +73 -0
- package/src/connectivity/manifest/identity.ts +43 -0
- package/src/connectivity/manifest/index.ts +46 -0
- package/src/connectivity/manifest/primitives.ts +20 -0
- package/src/connectivity/manifest/provider.ts +208 -0
- package/src/connectivity/manifest/requirements.ts +146 -0
- package/src/connectivity/manifest/setup.ts +55 -0
- package/src/connectivity/provider.ts +163 -0
- package/src/connectivity/transports/README.md +33 -0
- package/src/connectivity/transports/composite/index.ts +68 -0
- package/src/connectivity/transports/dav/calendar.ts +217 -0
- package/src/connectivity/transports/dav/capabilities.ts +151 -0
- package/src/connectivity/transports/dav/client.ts +226 -0
- package/src/connectivity/transports/dav/contacts.ts +75 -0
- package/src/connectivity/transports/dav/ical.ts +412 -0
- package/src/connectivity/transports/dav/index.ts +143 -0
- package/src/connectivity/transports/dav/operations.ts +43 -0
- package/src/connectivity/transports/dav/request.ts +161 -0
- package/src/connectivity/transports/dav/xml.ts +123 -0
- package/src/connectivity/transports/factory.ts +181 -0
- package/src/connectivity/transports/fs/capabilities.ts +88 -0
- package/src/connectivity/transports/fs/commands.ts +258 -0
- package/src/connectivity/transports/fs/index.ts +121 -0
- package/src/connectivity/transports/fs/operations.ts +45 -0
- package/src/connectivity/transports/fs/paths.ts +120 -0
- package/src/connectivity/transports/fs/result.ts +12 -0
- package/src/connectivity/transports/http/index.ts +255 -0
- package/src/connectivity/transports/imap/attachment.ts +166 -0
- package/src/connectivity/transports/imap/capabilities.ts +158 -0
- package/src/connectivity/transports/imap/client.ts +398 -0
- package/src/connectivity/transports/imap/commands.ts +385 -0
- package/src/connectivity/transports/imap/index.ts +152 -0
- package/src/connectivity/transports/imap/operations.ts +64 -0
- package/src/connectivity/transports/imap/parse.ts +130 -0
- package/src/connectivity/transports/imap/parser.ts +272 -0
- package/src/connectivity/transports/imap/result.ts +15 -0
- package/src/connectivity/transports/imap/send.ts +92 -0
- package/src/connectivity/transports/imap/socket.ts +111 -0
- package/src/connectivity/transports/imap/utf7.ts +136 -0
- package/src/connectivity/transports/index.ts +20 -0
- package/src/connectivity/transports/local/index.ts +173 -0
- package/src/connectivity/transports/mcp/index.ts +215 -0
- package/src/deployments/README.md +63 -0
- package/src/deployments/adapters/audit-blob.ts +203 -0
- package/src/deployments/adapters/filesystem.ts +184 -0
- package/src/deployments/adapters/gcp-secret-manager.ts +492 -0
- package/src/deployments/adapters/gcs.ts +191 -0
- package/src/deployments/adapters/otlp.ts +128 -0
- package/src/deployments/adapters/s3.ts +195 -0
- package/src/deployments/azure/README.md +21 -0
- package/src/deployments/bootstrap.ts +177 -0
- package/src/deployments/deploy.ts +290 -0
- package/src/deployments/driver.ts +157 -0
- package/src/deployments/drivers.ts +35 -0
- package/src/deployments/gcp/Dockerfile +70 -0
- package/src/deployments/gcp/cloudbuild.yaml +31 -0
- package/src/deployments/gcp/driver.ts +175 -0
- package/src/deployments/gcp/gcloud.ts +178 -0
- package/src/deployments/gcp/provision.ts +290 -0
- package/src/deployments/gcp/survey.ts +319 -0
- package/src/deployments/local/README.md +12 -0
- package/src/deployments/prepare.ts +257 -0
- package/src/deployments/steps.ts +137 -0
- package/src/deployments/target.ts +295 -0
- package/src/deployments/upload.ts +207 -0
- package/src/dispatch/context.ts +195 -0
- package/src/dispatch/dispatch.ts +350 -0
- package/src/dispatch/index.ts +32 -0
- package/src/dispatch/staging.ts +102 -0
- package/src/policy/index.ts +179 -0
- package/src/policy/limits.ts +77 -0
- package/src/profile/authorization.ts +81 -0
- package/src/profile/files.ts +71 -0
- package/src/profile/index.ts +76 -0
- package/src/profile/layout.ts +123 -0
- package/src/profile/load.ts +199 -0
- package/src/profile/primitives.ts +45 -0
- package/src/profile/schema.ts +347 -0
- package/src/profile/secret-detection.ts +162 -0
- package/src/profile/targets.ts +152 -0
- package/src/profile/workspace.ts +262 -0
- package/src/providers/custom/index.ts +21 -0
- package/src/providers/custom/load.ts +115 -0
- package/src/providers/custom/template.ts +156 -0
- package/src/providers/example/provider.ts +207 -0
- package/src/providers/google/calendar/index.ts +66 -0
- package/src/providers/google/calendar/redact.ts +40 -0
- package/src/providers/google/contacts/index.ts +50 -0
- package/src/providers/google/contacts/redact.ts +21 -0
- package/src/providers/google/docs/index.ts +45 -0
- package/src/providers/google/drive/hints.ts +28 -0
- package/src/providers/google/drive/index.ts +34 -0
- package/src/providers/google/drive/redact.ts +39 -0
- package/src/providers/google/drive-mcp/index.ts +21 -0
- package/src/providers/google/gmail/api.ts +42 -0
- package/src/providers/google/gmail/attachment.ts +142 -0
- package/src/providers/google/gmail/hints.ts +55 -0
- package/src/providers/google/gmail/index.ts +112 -0
- package/src/providers/google/gmail/redact.ts +56 -0
- package/src/providers/google/gmail/send.ts +365 -0
- package/src/providers/google/gmail-mcp/index.ts +35 -0
- package/src/providers/google/index.ts +10 -0
- package/src/providers/google/shared/oauth.ts +122 -0
- package/src/providers/google/shared/scopes.ts +99 -0
- package/src/providers/google/shared/setup.ts +80 -0
- package/src/providers/google/sheets/hints.ts +45 -0
- package/src/providers/google/sheets/index.ts +70 -0
- package/src/providers/google/sheets/redact.ts +45 -0
- package/src/providers/google/specs/calendar.v3.json +1829 -0
- package/src/providers/google/specs/docs.v1.json +381 -0
- package/src/providers/google/specs/drive.v3.json +2208 -0
- package/src/providers/google/specs/gmail.v1.json +2578 -0
- package/src/providers/google/specs/people.v1.json +506 -0
- package/src/providers/google/specs/sheets.v4.json +1269 -0
- package/src/providers/google/specs/tasks.v1.json +840 -0
- package/src/providers/google/specs/vendor.ts +661 -0
- package/src/providers/google/tasks/index.ts +53 -0
- package/src/providers/google/tasks/redact.ts +34 -0
- package/src/providers/harness.ts +95 -0
- package/src/providers/icloud/calendar/index.ts +27 -0
- package/src/providers/icloud/contacts/index.ts +17 -0
- package/src/providers/icloud/drive/index.ts +47 -0
- package/src/providers/icloud/index.ts +8 -0
- package/src/providers/icloud/mail/index.ts +37 -0
- package/src/providers/icloud/shared/setup.ts +66 -0
- package/src/providers/index.ts +93 -0
- package/src/providers/linear/index.ts +11 -0
- package/src/providers/linear/scopes.ts +7 -0
- package/src/providers/memory/provider.ts +429 -0
- package/src/providers/notion/index.ts +19 -0
- package/src/providers/owner.ts +49 -0
- package/src/providers/scopes.ts +26 -0
- package/src/providers/setup/plan.ts +141 -0
- package/src/providers/setup/provider.ts +323 -0
- package/src/providers/shared/frontmatter.ts +119 -0
- package/src/providers/skills/provider.ts +283 -0
- package/src/providers/skills/store.ts +252 -0
- package/src/providers/vault/provider.ts +194 -0
- package/src/registry/index.ts +36 -0
- package/src/registry/policy-bridge.ts +32 -0
- package/src/registry/reconcile.ts +313 -0
- package/src/registry/registry.ts +240 -0
- package/src/secrets/document.ts +293 -0
- package/src/secrets/index.ts +154 -0
- package/src/secrets/system.ts +173 -0
- package/src/secrets/vault.ts +336 -0
- package/src/server/attachments.ts +197 -0
- package/src/server/container.ts +96 -0
- package/src/server/edge.ts +53 -0
- package/src/server/endpoint.ts +352 -0
- package/src/server/generations.ts +362 -0
- package/src/server/harness.ts +400 -0
- package/src/server/index.ts +331 -0
- package/src/server/logging.ts +41 -0
- package/src/server/mcp/build.ts +68 -0
- package/src/server/mcp/icon.ts +145 -0
- package/src/server/mcp/index.ts +32 -0
- package/src/server/mcp/instructions.ts +245 -0
- package/src/server/mcp/naming.ts +39 -0
- package/src/server/mcp/prompts.ts +78 -0
- package/src/server/mcp/resources.ts +106 -0
- package/src/server/mcp/routing.ts +117 -0
- package/src/server/mcp/schema.ts +78 -0
- package/src/server/mcp/tools.ts +186 -0
- package/src/server/mcp/visibility.ts +132 -0
- package/src/server/oauth.ts +222 -0
- package/src/server/rebinding.ts +53 -0
- package/src/server/stdio.ts +192 -0
- package/src/stores/blobs/conformance.ts +223 -0
- package/src/stores/blobs/index.ts +125 -0
- package/src/stores/blobs/testing.ts +49 -0
- package/src/stores/state/index.ts +247 -0
- package/src/stores/state/keys.ts +68 -0
- package/src/stores/state/testing.ts +41 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { fromJsonSchema, type McpServer } from '@modelcontextprotocol/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { isToolResult } from '#connectivity';
|
|
4
|
+
import { toolNameFor } from './naming.ts';
|
|
5
|
+
import { resourceLinkRouter } from './routing.ts';
|
|
6
|
+
import { sanitizeSchema } from './schema.ts';
|
|
7
|
+
import { describeWithConnections, type BuildServerOptions, type MergedCapability } from './visibility.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Tools — the capability kind everything else is measured against.
|
|
11
|
+
*
|
|
12
|
+
* Two registration paths and one handler. Discovered capabilities carry JSON
|
|
13
|
+
* Schema, because they come from an upstream MCP server or an OpenAPI document;
|
|
14
|
+
* local ones author Zod. Both end up as the same registered tool, with `profile`
|
|
15
|
+
* and `connection` injected either way — ADR-001 does not change just because
|
|
16
|
+
* the schema arrived differently.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The two arguments core injects, and no provider declares. */
|
|
20
|
+
function routingProperties(profiles: string[], connections: string[]): Record<string, unknown> {
|
|
21
|
+
return {
|
|
22
|
+
profile: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
enum: profiles,
|
|
25
|
+
description: 'Which profile to act within',
|
|
26
|
+
},
|
|
27
|
+
connection: {
|
|
28
|
+
type: 'string',
|
|
29
|
+
enum: connections,
|
|
30
|
+
description: 'Which configured account to act on, within that profile',
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function registerDiscoveredTool(
|
|
36
|
+
server: McpServer,
|
|
37
|
+
id: string,
|
|
38
|
+
entry: MergedCapability,
|
|
39
|
+
options: BuildServerOptions,
|
|
40
|
+
): void {
|
|
41
|
+
const discovered = entry.discovered!;
|
|
42
|
+
const profiles = [...entry.reachable.keys()];
|
|
43
|
+
const connections = [...new Set([...entry.reachable.values()].flat())];
|
|
44
|
+
|
|
45
|
+
const properties = (discovered.inputSchema['properties'] as Record<string, unknown>) ?? {};
|
|
46
|
+
const required = (discovered.inputSchema['required'] as string[]) ?? [];
|
|
47
|
+
|
|
48
|
+
server.registerTool(
|
|
49
|
+
toolNameFor(id),
|
|
50
|
+
{
|
|
51
|
+
...(discovered.title ? { title: discovered.title } : {}),
|
|
52
|
+
description: describeWithConnections(discovered.description, entry.reachable),
|
|
53
|
+
// Spread the upstream schema rather than rebuilding it from properties
|
|
54
|
+
// and required alone. Vendors put `$defs` beside those and `$ref` into
|
|
55
|
+
// them — Linear's attachment tools do — and a rebuild drops the
|
|
56
|
+
// definitions while keeping the references, leaving a schema that cannot
|
|
57
|
+
// resolve itself. That failure only appears once a tool using it is
|
|
58
|
+
// actually registered, which is why a catch-all grant surfaced it and a
|
|
59
|
+
// read-only one never did.
|
|
60
|
+
inputSchema: fromJsonSchema(
|
|
61
|
+
sanitizeSchema({
|
|
62
|
+
...discovered.inputSchema,
|
|
63
|
+
type: 'object',
|
|
64
|
+
properties: { ...properties, ...routingProperties(profiles, connections) },
|
|
65
|
+
required: [...required, 'profile', 'connection'],
|
|
66
|
+
}),
|
|
67
|
+
),
|
|
68
|
+
},
|
|
69
|
+
makeHandler(id, entry, options),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function registerLocalTool(
|
|
74
|
+
server: McpServer,
|
|
75
|
+
id: string,
|
|
76
|
+
entry: MergedCapability,
|
|
77
|
+
capability: Extract<NonNullable<MergedCapability['capability']>, { kind: 'tool' }>,
|
|
78
|
+
options: BuildServerOptions,
|
|
79
|
+
): void {
|
|
80
|
+
const profiles = [...entry.reachable.keys()];
|
|
81
|
+
const connections = [...new Set([...entry.reachable.values()].flat())];
|
|
82
|
+
const shape = (capability.inputSchema as unknown as { shape?: z.ZodRawShape }).shape ?? {};
|
|
83
|
+
|
|
84
|
+
server.registerTool(
|
|
85
|
+
toolNameFor(id),
|
|
86
|
+
{
|
|
87
|
+
...(capability.title ? { title: capability.title } : {}),
|
|
88
|
+
description: describeWithConnections(capability.description, entry.reachable),
|
|
89
|
+
inputSchema: {
|
|
90
|
+
...shape,
|
|
91
|
+
// Injected by core, never declared by a provider — ADR-001. Both enums
|
|
92
|
+
// are built from resolved policy, so they double as the discovery
|
|
93
|
+
// filter.
|
|
94
|
+
profile: z.enum(profiles as [string, ...string[]]).describe('Which profile to act within'),
|
|
95
|
+
connection: z
|
|
96
|
+
.enum(connections as [string, ...string[]])
|
|
97
|
+
.describe('Which configured account to act on, within that profile'),
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
makeHandler(id, entry, options),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* One tool handler, shared by local and discovered capabilities.
|
|
106
|
+
*
|
|
107
|
+
* `connection` is stripped here rather than inside a connector: it is injected
|
|
108
|
+
* by us and means nothing upstream, so forwarding it would leak our routing
|
|
109
|
+
* detail into someone else's API.
|
|
110
|
+
*/
|
|
111
|
+
function makeHandler(capabilityId: string, entry: MergedCapability, options: BuildServerOptions) {
|
|
112
|
+
// `unknown` because the JSON-Schema overload types it that way; the schema
|
|
113
|
+
// has already validated the shape by the time this runs.
|
|
114
|
+
return async (args: unknown) => {
|
|
115
|
+
const { profile, connection, ...rest } = (args ?? {}) as Record<string, unknown>;
|
|
116
|
+
|
|
117
|
+
const name = String(profile);
|
|
118
|
+
const runtime = options.profiles.get(name);
|
|
119
|
+
const reachable = entry.reachable.get(name);
|
|
120
|
+
|
|
121
|
+
// The enums are a union across profiles, so a caller can name a valid
|
|
122
|
+
// profile and a connection that belongs to a different one. Refuse it here
|
|
123
|
+
// rather than dispatching: routing a `work` account through `personal`
|
|
124
|
+
// would cross exactly the boundary profiles exist to hold.
|
|
125
|
+
if (!runtime || !reachable) {
|
|
126
|
+
return {
|
|
127
|
+
content: [
|
|
128
|
+
{
|
|
129
|
+
type: 'text' as const,
|
|
130
|
+
text: `Profile "${name}" does not offer ${capabilityId}. Available: ${[...entry.reachable.keys()].join(', ')}`,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
isError: true,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!reachable.includes(String(connection))) {
|
|
138
|
+
return {
|
|
139
|
+
content: [
|
|
140
|
+
{
|
|
141
|
+
type: 'text' as const,
|
|
142
|
+
text: `Connection "${String(connection)}" is not part of profile "${name}". Available there: ${reachable.join(', ')}`,
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
isError: true,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const outcome = await runtime.dispatcher.invoke({
|
|
150
|
+
principal: options.principal,
|
|
151
|
+
capabilityId,
|
|
152
|
+
connectionKey: String(connection),
|
|
153
|
+
arguments: rest,
|
|
154
|
+
clientLabel: options.clientLabel,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (!outcome.ok) {
|
|
158
|
+
// A refusal is a tool error rather than a protocol error: the agent should
|
|
159
|
+
// be able to read it, explain it, and pick something else, not lose the
|
|
160
|
+
// connection.
|
|
161
|
+
return { content: [{ type: 'text' as const, text: outcome.message }], isError: true };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// A tool capability always produces a `ToolResult`; the guard is what makes
|
|
165
|
+
// that structural rather than assumed, now that dispatch can return three
|
|
166
|
+
// other shapes.
|
|
167
|
+
if (!isToolResult(outcome.result)) {
|
|
168
|
+
return {
|
|
169
|
+
content: [{ type: 'text' as const, text: `${capabilityId} is not a tool` }],
|
|
170
|
+
isError: true,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const result = outcome.result;
|
|
175
|
+
const route = resourceLinkRouter(runtime, capabilityId, name, String(connection));
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
content: result.content.map((block) =>
|
|
179
|
+
block.type === 'text'
|
|
180
|
+
? { type: 'text' as const, text: block.text }
|
|
181
|
+
: { type: 'resource_link' as const, uri: route(block.uri), name: block.name ?? block.uri },
|
|
182
|
+
),
|
|
183
|
+
...(result.isError ? { isError: true } : {}),
|
|
184
|
+
};
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { Principal } from '#auth';
|
|
2
|
+
import type { Config } from '#profile';
|
|
3
|
+
import type { ProviderRegistry } from '#registry';
|
|
4
|
+
import type { Dispatcher } from '#dispatch';
|
|
5
|
+
import type { PolicyDocument } from '#policy';
|
|
6
|
+
import { allowedConnections } from '#policy';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What this principal can see, and therefore what gets registered at all.
|
|
10
|
+
*
|
|
11
|
+
* The server is a pure function of resolved policy: a capability the principal
|
|
12
|
+
* cannot reach on any connection is not registered, and one it can reach on some
|
|
13
|
+
* connections advertises exactly those in its `connection` enum. A client
|
|
14
|
+
* therefore cannot discover a connection it has no grant for.
|
|
15
|
+
*
|
|
16
|
+
* Discovery filtering and invocation enforcement share one implementation
|
|
17
|
+
* (`allowedConnections`, which calls the same `evaluate` the dispatcher uses).
|
|
18
|
+
* If they were computed separately they could drift, and a leak in discovery is
|
|
19
|
+
* still a leak.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Everything one profile contributes to the endpoint. */
|
|
23
|
+
export interface ProfileRuntime {
|
|
24
|
+
readonly config: Config;
|
|
25
|
+
readonly registry: ProviderRegistry;
|
|
26
|
+
readonly dispatcher: Dispatcher;
|
|
27
|
+
readonly policy: PolicyDocument;
|
|
28
|
+
readonly floor?: PolicyDocument | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Re-read the skills into `registry`, if they have changed on the store.
|
|
31
|
+
*
|
|
32
|
+
* Optional because only a served endpoint has one — a registry built to read
|
|
33
|
+
* manifests has nothing to refresh. Cheap and idempotent; the endpoint decides
|
|
34
|
+
* how often to ask (ADR-014).
|
|
35
|
+
*/
|
|
36
|
+
refreshSkills?(): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface BuildServerOptions {
|
|
40
|
+
/**
|
|
41
|
+
* Every profile this endpoint serves, keyed by name.
|
|
42
|
+
*
|
|
43
|
+
* One port, several profiles, and `profile` injected into each tool beside
|
|
44
|
+
* `connection`. What this trades away is worth naming: a token used to open
|
|
45
|
+
* exactly one profile, so a leaked one reached exactly one set of accounts.
|
|
46
|
+
* Now a single token reaches all of them and the *caller* chooses, which makes
|
|
47
|
+
* cross-profile access a matter of what the model decides to pass. Policy is
|
|
48
|
+
* still enforced per profile, and every call records which one.
|
|
49
|
+
*/
|
|
50
|
+
readonly profiles: ReadonlyMap<string, ProfileRuntime>;
|
|
51
|
+
readonly principal: Principal;
|
|
52
|
+
/** Self-reported by the client. Recorded in audit; never used to authorize. */
|
|
53
|
+
readonly clientLabel?: string | undefined;
|
|
54
|
+
readonly version?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One profile as the map the builder wants. */
|
|
58
|
+
export function oneProfile(
|
|
59
|
+
name: string,
|
|
60
|
+
runtime: ProfileRuntime,
|
|
61
|
+
): ReadonlyMap<string, ProfileRuntime> {
|
|
62
|
+
return new Map([[name, runtime]]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function connectionsOf(runtime: ProfileRuntime): string[] {
|
|
66
|
+
return runtime.config.connections.map((connection) => `${connection.provider}.${connection.id}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* What each profile exposes of one capability, merged.
|
|
71
|
+
*
|
|
72
|
+
* A capability is registered once even when several profiles offer it — two
|
|
73
|
+
* mailboxes are still one `gmail.users.messages.list` tool — with the profile
|
|
74
|
+
* chosen per call. `reachable` stays per profile because the connection enum
|
|
75
|
+
* must not imply that an account of one profile can be used through another.
|
|
76
|
+
*/
|
|
77
|
+
export interface MergedCapability {
|
|
78
|
+
readonly reachable: Map<string, string[]>;
|
|
79
|
+
readonly capability: ReturnType<ProviderRegistry['capabilities']>[number]['capability'];
|
|
80
|
+
readonly discovered: ReturnType<ProviderRegistry['capabilities']>[number]['discovered'];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function mergeCapabilities(options: BuildServerOptions): Map<string, MergedCapability> {
|
|
84
|
+
const merged = new Map<string, MergedCapability>();
|
|
85
|
+
|
|
86
|
+
for (const [name, runtime] of options.profiles) {
|
|
87
|
+
const connections = connectionsOf(runtime);
|
|
88
|
+
|
|
89
|
+
for (const { id, capability, discovered } of runtime.registry.capabilities()) {
|
|
90
|
+
const reachable = allowedConnections(
|
|
91
|
+
id,
|
|
92
|
+
connections,
|
|
93
|
+
options.principal.id,
|
|
94
|
+
runtime.policy,
|
|
95
|
+
runtime.floor,
|
|
96
|
+
);
|
|
97
|
+
if (reachable.length === 0) continue;
|
|
98
|
+
|
|
99
|
+
const existing = merged.get(id);
|
|
100
|
+
if (existing) {
|
|
101
|
+
existing.reachable.set(name, reachable);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
merged.set(id, { reachable: new Map([[name, reachable]]), capability, discovered });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return merged;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Which capability ids this principal can reach, across every profile served. */
|
|
113
|
+
export function visibleCapabilities(options: BuildServerOptions): string[] {
|
|
114
|
+
return [...mergeCapabilities(options).keys()];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Say which accounts are reachable, grouped by profile.
|
|
119
|
+
*
|
|
120
|
+
* Grouped rather than flattened because the two arguments are not independent:
|
|
121
|
+
* `profile: personal` with a connection belonging to `work` is refused, and a
|
|
122
|
+
* flat list would read as though any pairing were valid.
|
|
123
|
+
*/
|
|
124
|
+
export function describeWithConnections(
|
|
125
|
+
description: string,
|
|
126
|
+
reachable: ReadonlyMap<string, readonly string[]>,
|
|
127
|
+
): string {
|
|
128
|
+
const lines = [...reachable].map(
|
|
129
|
+
([profile, connections]) => ` ${profile}: ${connections.join(', ')}`,
|
|
130
|
+
);
|
|
131
|
+
return `${description}\n\nAvailable connections, by profile:\n${lines.join('\n')}`;
|
|
132
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import {
|
|
2
|
+
authorizationServerMetadata,
|
|
3
|
+
protectedResourceMetadata,
|
|
4
|
+
type AuthorizeRequest,
|
|
5
|
+
type OAuthResult,
|
|
6
|
+
type OAuthServer,
|
|
7
|
+
} from '#auth';
|
|
8
|
+
import { approvalPage } from '#cli/callback-page.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The HTTP surface of the authorization flow.
|
|
12
|
+
*
|
|
13
|
+
* Its own file so the router stays a router: `index.ts` gains a delegation, the
|
|
14
|
+
* way it already delegates attachments, rather than six more branches and a
|
|
15
|
+
* form parser. It also keeps that file inside its size budget, which is the
|
|
16
|
+
* rule that would otherwise be relaxed to fit this in.
|
|
17
|
+
*
|
|
18
|
+
* Everything here is transport work — parse, dispatch, render. The decisions
|
|
19
|
+
* live in `#auth`, which is what makes the flow testable as a sequence of
|
|
20
|
+
* values rather than as a browser session.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export const PROTECTED_RESOURCE_PATH = '/.well-known/oauth-protected-resource';
|
|
24
|
+
export const AUTHORIZATION_SERVER_PATH = '/.well-known/oauth-authorization-server';
|
|
25
|
+
const REGISTER_PATH = '/register';
|
|
26
|
+
const AUTHORIZE_PATH = '/authorize';
|
|
27
|
+
const TOKEN_PATH = '/token';
|
|
28
|
+
|
|
29
|
+
export interface AuthorizationSurface {
|
|
30
|
+
/** Present only in `self` mode; `oidc` publishes metadata and issues nothing. */
|
|
31
|
+
readonly server?: OAuthServer | undefined;
|
|
32
|
+
/** Where a client should go to get a token — this origin, or an issuer's. */
|
|
33
|
+
readonly issuer: (origin: string) => string;
|
|
34
|
+
/** The MCP endpoint path, so `resource` names what the client actually calls. */
|
|
35
|
+
readonly mcpPath: string;
|
|
36
|
+
/**
|
|
37
|
+
* The target this endpoint runs as, so the consent page can name the store its
|
|
38
|
+
* token actually lives in. Credentials are per-target, and the reader is about
|
|
39
|
+
* to run a command in a shell that resolves a target of its own — usually
|
|
40
|
+
* `local`, which is the one store a deployed endpoint's token is never in.
|
|
41
|
+
*/
|
|
42
|
+
readonly target: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Every path this surface answers, so the router can ask before authenticating. */
|
|
46
|
+
export function isAuthorizationPath(pathname: string): boolean {
|
|
47
|
+
return (
|
|
48
|
+
pathname === PROTECTED_RESOURCE_PATH ||
|
|
49
|
+
pathname.startsWith(`${PROTECTED_RESOURCE_PATH}/`) ||
|
|
50
|
+
pathname === AUTHORIZATION_SERVER_PATH ||
|
|
51
|
+
pathname === REGISTER_PATH ||
|
|
52
|
+
pathname === AUTHORIZE_PATH ||
|
|
53
|
+
pathname === TOKEN_PATH
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The origin a client used to reach here.
|
|
59
|
+
*
|
|
60
|
+
* Not `request.url`, and not config. Cloud Run terminates TLS and forwards the
|
|
61
|
+
* original scheme in a header, so a URL built from the incoming request says
|
|
62
|
+
* `http` and every metadata document would name a resource no client asked for
|
|
63
|
+
* — which fails the exact-match the specification requires. Config cannot help
|
|
64
|
+
* either: the hostname carries a project hash assigned at deploy time.
|
|
65
|
+
*/
|
|
66
|
+
export function publicOrigin(request: Request): string {
|
|
67
|
+
const url = new URL(request.url);
|
|
68
|
+
const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? url.host;
|
|
69
|
+
const proto = request.headers.get('x-forwarded-proto') ?? url.protocol.replace(':', '');
|
|
70
|
+
return `${proto}://${host}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function resourceMetadataUrl(request: Request): string {
|
|
74
|
+
return `${publicOrigin(request)}${PROTECTED_RESOURCE_PATH}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function handleAuthorization(
|
|
78
|
+
request: Request,
|
|
79
|
+
surface: AuthorizationSurface,
|
|
80
|
+
): Promise<Response> {
|
|
81
|
+
const url = new URL(request.url);
|
|
82
|
+
const origin = publicOrigin(request);
|
|
83
|
+
const path = url.pathname;
|
|
84
|
+
|
|
85
|
+
// Both spellings: the bare document, and the one suffixed with the resource's
|
|
86
|
+
// own path, which is what a client probes first when the `401` pointed it
|
|
87
|
+
// nowhere. Answering both costs a comparison and removes a failure mode.
|
|
88
|
+
if (path === PROTECTED_RESOURCE_PATH || path.startsWith(`${PROTECTED_RESOURCE_PATH}/`)) {
|
|
89
|
+
return json(
|
|
90
|
+
protectedResourceMetadata({
|
|
91
|
+
resource: `${origin}${surface.mcpPath}`,
|
|
92
|
+
issuer: surface.issuer(origin),
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (path === AUTHORIZATION_SERVER_PATH) {
|
|
98
|
+
// Only meaningful when this endpoint *is* the authorization server. Pointed
|
|
99
|
+
// at an external issuer, the client reads that issuer's document instead,
|
|
100
|
+
// and answering here with ours would send it to endpoints that do not exist.
|
|
101
|
+
if (!surface.server) return new Response('Not found', { status: 404 });
|
|
102
|
+
return json(authorizationServerMetadata(origin));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const server = surface.server;
|
|
106
|
+
if (!server) return new Response('Not found', { status: 404 });
|
|
107
|
+
|
|
108
|
+
if (path === REGISTER_PATH && request.method === 'POST') {
|
|
109
|
+
return render(await server.register(await safeJson(request)), request, surface.target);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (path === AUTHORIZE_PATH) {
|
|
113
|
+
if (request.method === 'GET') {
|
|
114
|
+
return render(await server.authorize(url.searchParams), request, surface.target);
|
|
115
|
+
}
|
|
116
|
+
if (request.method === 'POST') {
|
|
117
|
+
const form = new URLSearchParams(await request.text());
|
|
118
|
+
return render(
|
|
119
|
+
await server.approve(requestFromForm(form), form.get('token') ?? ''),
|
|
120
|
+
request,
|
|
121
|
+
surface.target,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (path === TOKEN_PATH && request.method === 'POST') {
|
|
127
|
+
return render(
|
|
128
|
+
await server.token(new URLSearchParams(await request.text())),
|
|
129
|
+
request,
|
|
130
|
+
surface.target,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return new Response('Method not allowed', { status: 405 });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function render(result: OAuthResult, request: Request, target: string): Response {
|
|
138
|
+
switch (result.kind) {
|
|
139
|
+
case 'json':
|
|
140
|
+
return json(result.body, result.status);
|
|
141
|
+
|
|
142
|
+
case 'redirect':
|
|
143
|
+
return new Response(null, { status: 302, headers: { location: result.location } });
|
|
144
|
+
|
|
145
|
+
case 'consent':
|
|
146
|
+
return approvalPage({
|
|
147
|
+
// The name if it gave one, the identifier if not. Either way the
|
|
148
|
+
// redirect host goes on the screen beside it — a client may call itself
|
|
149
|
+
// anything, but it cannot change where the code is sent.
|
|
150
|
+
client: result.clientName ?? result.request.clientId,
|
|
151
|
+
redirectHost: hostOf(result.request.redirectUri),
|
|
152
|
+
action: `${publicOrigin(request)}${AUTHORIZE_PATH}`,
|
|
153
|
+
fields: formFromRequest(result.request),
|
|
154
|
+
retry: result.retry,
|
|
155
|
+
target,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
case 'error':
|
|
159
|
+
return new Response(result.message, { status: result.status });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The authorization request, carried through the approval form.
|
|
165
|
+
*
|
|
166
|
+
* Round-tripped through hidden fields rather than held in a server-side session:
|
|
167
|
+
* the deployed endpoint replaces instances between requests, so a session begun
|
|
168
|
+
* on one and submitted to another would be gone. Nothing here is a secret — the
|
|
169
|
+
* client sent all of it in the query string — and none of it is trusted on the
|
|
170
|
+
* way back, because `approve` re-checks the client and the redirect URI against
|
|
171
|
+
* what is registered before it mints anything.
|
|
172
|
+
*/
|
|
173
|
+
function formFromRequest(request: AuthorizeRequest): Record<string, string> {
|
|
174
|
+
return {
|
|
175
|
+
client_id: request.clientId,
|
|
176
|
+
redirect_uri: request.redirectUri,
|
|
177
|
+
code_challenge: request.codeChallenge,
|
|
178
|
+
scope: request.scope,
|
|
179
|
+
...(request.state !== undefined ? { state: request.state } : {}),
|
|
180
|
+
...(request.resource !== undefined ? { resource: request.resource } : {}),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function requestFromForm(form: URLSearchParams): AuthorizeRequest {
|
|
185
|
+
return {
|
|
186
|
+
clientId: form.get('client_id') ?? '',
|
|
187
|
+
redirectUri: form.get('redirect_uri') ?? '',
|
|
188
|
+
codeChallenge: form.get('code_challenge') ?? '',
|
|
189
|
+
scope: form.get('scope') ?? '',
|
|
190
|
+
state: form.get('state') ?? undefined,
|
|
191
|
+
resource: form.get('resource') ?? undefined,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function hostOf(uri: string): string {
|
|
196
|
+
try {
|
|
197
|
+
return new URL(uri).host;
|
|
198
|
+
} catch {
|
|
199
|
+
return uri;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function safeJson(request: Request): Promise<unknown> {
|
|
204
|
+
try {
|
|
205
|
+
return await request.json();
|
|
206
|
+
} catch {
|
|
207
|
+
return {};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function json(body: unknown, status = 200): Response {
|
|
212
|
+
return new Response(JSON.stringify(body), {
|
|
213
|
+
status,
|
|
214
|
+
headers: {
|
|
215
|
+
'content-type': 'application/json',
|
|
216
|
+
// Discovery documents are public by definition and read on every fresh
|
|
217
|
+
// connection; `no-store` on the token endpoint is the part that matters,
|
|
218
|
+
// and it is the default for a POST anyway.
|
|
219
|
+
'cache-control': 'no-store',
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {
|
|
2
|
+
hostHeaderValidationResponse,
|
|
3
|
+
localhostAllowedHostnames,
|
|
4
|
+
originValidationResponse,
|
|
5
|
+
} from '@modelcontextprotocol/server';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* DNS-rebinding protection for a loopback endpoint.
|
|
9
|
+
*
|
|
10
|
+
* The attack this exists for needs no bug to work. An endpoint on 127.0.0.1 is
|
|
11
|
+
* reachable by any page the owner happens to be visiting: the attacker serves a
|
|
12
|
+
* short-TTL record, rebinds it to loopback, and the browser then calls this
|
|
13
|
+
* endpoint same-origin — at which point CORS stops applying and responses
|
|
14
|
+
* become readable. What it reaches is everything that answers before
|
|
15
|
+
* authentication, which is `/health`, the discovery documents, `/register`, and
|
|
16
|
+
* the `/authorize` consent form that asks for the owner's token.
|
|
17
|
+
*
|
|
18
|
+
* The check belongs here rather than in the MCP handler because the SDK's entry
|
|
19
|
+
* is documented as deliberately validation-free and expects this in front of
|
|
20
|
+
* it. The helpers are the SDK's for the same reason `quoted` is one function: a
|
|
21
|
+
* second implementation of a check is a second thing to drift.
|
|
22
|
+
*
|
|
23
|
+
* Only for loopback. A routable deployment gets nothing from this — rebinding
|
|
24
|
+
* to a public address buys an attacker no reach the address did not already
|
|
25
|
+
* give — and its hostname is assigned by the platform rather than known here,
|
|
26
|
+
* so a fixed allowlist would refuse every legitimate request instead.
|
|
27
|
+
*/
|
|
28
|
+
export function allowedHostnamesFor(host: string, isLoopbackHost: boolean): string[] | undefined {
|
|
29
|
+
if (!isLoopbackHost) return undefined;
|
|
30
|
+
|
|
31
|
+
// The SDK's list rather than our own `LOOPBACK` set: this one has to match
|
|
32
|
+
// what a *browser* puts in a Host header, which means `[::1]` in brackets and
|
|
33
|
+
// every `*.localhost` name, not just the three spellings we bind to.
|
|
34
|
+
const allowed = localhostAllowedHostnames();
|
|
35
|
+
return allowed.includes(host) ? allowed : [...allowed, host];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The refusal, or `undefined` to let the request through.
|
|
40
|
+
*
|
|
41
|
+
* Host and Origin answer different questions — where the request thinks it is
|
|
42
|
+
* going, and where it came from — so both are checked. A missing Origin passes
|
|
43
|
+
* by design: no non-browser client sends one, and every MCP client is one.
|
|
44
|
+
*/
|
|
45
|
+
export function rebindingRefusal(
|
|
46
|
+
request: Request,
|
|
47
|
+
allowedHostnames: readonly string[],
|
|
48
|
+
): Response | undefined {
|
|
49
|
+
const allowed = [...allowedHostnames];
|
|
50
|
+
return (
|
|
51
|
+
hostHeaderValidationResponse(request, allowed) ?? originValidationResponse(request, allowed)
|
|
52
|
+
);
|
|
53
|
+
}
|