@pikku/core 0.12.113 → 0.12.114

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.
Files changed (29) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/function/function-runner.js +8 -2
  3. package/dist/middleware-runner.js +8 -3
  4. package/dist/services/credential-wire-service.d.ts +31 -1
  5. package/dist/services/credential-wire-service.js +47 -10
  6. package/dist/services/typed-secret-service.d.ts +11 -0
  7. package/dist/services/typed-secret-service.js +11 -1
  8. package/dist/types/state.types.d.ts +2 -1
  9. package/dist/wirings/addon/addon-runner.d.ts +2 -1
  10. package/dist/wirings/addon/wire-addon.d.ts +7 -2
  11. package/dist/wirings/credential/credential-overrides.d.ts +26 -0
  12. package/dist/wirings/credential/credential-overrides.js +45 -0
  13. package/dist/wirings/credential/index.d.ts +2 -0
  14. package/dist/wirings/credential/index.js +1 -0
  15. package/dist/wirings/mcp/index.d.ts +2 -1
  16. package/dist/wirings/mcp/index.js +1 -1
  17. package/dist/wirings/mcp/mcp-runner.d.ts +12 -0
  18. package/dist/wirings/mcp/mcp-runner.js +73 -0
  19. package/dist/wirings/rpc/rpc-types.d.ts +2 -1
  20. package/dist/wirings/secret/derive-oauth2-app-secrets.d.ts +20 -0
  21. package/dist/wirings/secret/derive-oauth2-app-secrets.js +47 -0
  22. package/dist/wirings/secret/index.d.ts +1 -0
  23. package/dist/wirings/secret/index.js +1 -0
  24. package/knowledge/decisions/internals/global-middleware-resolves-from-the-root-namespace-too.md +32 -0
  25. package/knowledge/decisions/internals/index.md +3 -0
  26. package/knowledge/decisions/internals/mcp-wire-names-are-assigned-over-the-whole-registry.md +39 -0
  27. package/knowledge/decisions/internals/the-mcp-handshake-is-challenged-only-when-every-target-is-gated.md +29 -0
  28. package/package.json +1 -1
  29. package/src/public-surface.json +6 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,66 @@
1
+ ## 0.12.114
2
+
3
+ ### Patch Changes
4
+
5
+ - cb8f57e: Global middleware now runs for a dispatch that belongs to an addon, not just for one in the root namespace.
6
+
7
+ `combineMiddleware` read the global list out of a single namespace — the `packageName` the dispatch carried. For anything wired by the application itself that namespace is `null`, so it worked; for a wiring contributed by an addon (an MCP tool registered through `wireAddon({ mcp: [...] })`, say) it was the addon's own package name, and the application's globals were simply not in that list. The practical effect was that an addon's tools ran with no session middleware at all: every one of them refused with "authentication required" no matter who was calling, while the identical function reached through HTTP authenticated fine.
8
+
9
+ Global middleware is application-wide by definition, and an addon's function is still running inside the host application, so the root namespace always applies. A dispatch in the root namespace resolves `[null]` as before; one in an addon resolves `[null, packageName]` — the host's globals first, then the addon's own. Globals registered by one addon still do not reach another's dispatches.
10
+
11
+ - cb8f57e: MCP clients can now discover that a server needs signing in to, and can see tools whose names carry a namespace.
12
+
13
+ Three separate reasons a client ended up connected to a server it could do nothing with:
14
+
15
+ - **The handshake was never challenged.** A client decides at connection time whether a server speaks OAuth, and all it has to go on is whether `initialize` came back `401` with a `WWW-Authenticate` challenge. Answering it `200` and then refusing every subsequent call told the client "no sign-in needed" and then gave it nothing — Claude's connector setup, for one, reported the server as open access. `initialize` is now challenged when `mcpEveryTargetRequiresSession()` holds, which is the only case where answering it openly is a lie; a server with even one open target still completes the handshake unauthenticated, as it should.
16
+ - **Namespaced names were dropped on the floor.** MCP constrains tool and prompt names to `[A-Za-z0-9_-]{1,64}`, and pikku's namespace separator is `:`. Clients discard the names they cannot accept rather than failing the connection, so an addon's entire surface went missing with nothing more than a note about "tools with unsupported names". Names are now rewritten at the transport boundary (`mcpWireName`) and resolved back on the way in (`mcpResolveWireName`), so `bb2:getMe` is offered as `bb2_getMe` and calls to it dispatch correctly. The registry keeps its own spelling, since that is what dispatch keys on, and a tool genuinely registered under the wire spelling still wins over a rewritten match.
17
+ - **An addon-only app generated no tool metadata.** `pikku all` decided whether to emit the MCP file from the set of source files calling `wireMCPTool`/`Resource`/`Prompt`. An addon that contributes its tools through `wireAddon({ mcp: [...] })` adds none, so an application whose only MCP surface came from addons got neither wirings nor meta — the tools were listed from `mcp.gen.json` and then every call failed to resolve, because `toolsMeta` (which carries the `pikkuFuncId`) had never been written. Content now decides; the file set only decides whether there are imports to serialize.
18
+
19
+ Also in here: the resource URL advertised in the challenge honours `X-Forwarded-Proto` and `X-Forwarded-Host`, so a server behind a TLS-terminating proxy advertises the `https://` URL the client actually reached rather than its own internal `http://` origin — which the client would reject as a resource mismatch.
20
+
21
+ - 6db6a14: A wiring can now decide whether an addon's credential is per-user or deployment-wide
22
+
23
+ `wireAddon`'s `credentialOverrides` takes an object as well as a rename string:
24
+
25
+ ```ts
26
+ wireAddon({
27
+ name: 'gmail',
28
+ package: '@pikku/addon-gmail',
29
+ credentialOverrides: {
30
+ gmailOAuth: { mode: 'wire' }, // each user connects their own
31
+ calendarOAuth: { name: 'CAL', mode: 'singleton' },
32
+ },
33
+ })
34
+ ```
35
+
36
+ An addon author declares a default with `defineCredential`; the deployment decides, so one addon serves both a per-user product and a single team account.
37
+
38
+ The wire credential service now resolves each credential by what it _is_ rather than by what a lookup returned: `wire` reads only the user's value, `singleton` reads the deployment's. A per-user credential can no longer pick up a platform-level value because the user has not connected — which, before, would have quietly run someone's request against the deployment's own account.
39
+
40
+ A credential is never read from the secret vault. The wire credential service no longer takes a `SecretService` at all, so the only way a credential arrives is the one its mode names — the user's own value, or the deployment's.
41
+
42
+ Modes are resolved at generation time into the credentials meta, so the console's connect flow reflects the wiring rather than the addon's default.
43
+
44
+ Two credentials that resolve to one name are rejected unless they agree on the mode. Generation writes a single metadata entry per name, so a `wire` credential aliased onto a `singleton` one used to take whichever wiring was read last — a per-user credential served from the deployment's own account, or a deployment credential handed out per user. The inspector reports the collision against both wirings by name, and `buildCredentialResolutions` refuses it at runtime too.
45
+
46
+ A mode-only override is checked against the credential it names. `{ mode: 'wire' }` renames nothing, so nothing was validated: an override naming a credential that does not exist was dropped in silence and the credential kept the mode its author declared.
47
+
48
+ A credential the wiring resolves as `singleton` is no longer read out of the user's own store. Per-user values are imported first and a name already present is left alone, so a value stored under a singleton's name — from an earlier wiring, or a connect flow since rewired — decided what the deployment's own slot resolved to. It is now skipped by mode, the same way a `wire` credential is kept away from the deployment's value.
49
+
50
+ The project's own credentials are registered into pikku state from the generated credentials file, so `wire.getCredential` resolves an app-level singleton the same way it resolves an addon's.
51
+
52
+ `pikku new addon` no longer requires a `pikku.config.json` in the working directory. Scaffolding an addon is something you do before a project config exists, so the command now reads one when it is there and falls back to the working directory when it is not.
53
+
54
+ An addon's `pikkuAddonWireServices` factory now declares the same service contract its singleton factory does: what it destructures off the parent's bag is required, and what it returns is built by the addon. Before, a service an addon built per wire was demanded from the consumer, and a wire-only addon declared no contract at all. A nested callback that names its own parameter after the factory's is no longer read as the factory's own: what it destructured went onto the addon's contract, so a consumer was asked for a service the addon never wanted from them.
55
+
56
+ An OAuth2 credential now implies its app secret, so nobody hand-writes one. The client id and secret an OAuth app needs is the same shape every time — `OAuth2AppCredential`, which is what the runtime already reads it as — so the inspector registers a secret for each credential's `appCredentialSecretId`. A hand-written `defineSecret` covering that id still wins, so an author who wants their own description or `docsUrl` keeps it. The derived secret is optional, matching what every hand-written declaration chose: an addon that also authenticates by API key must still deploy without an OAuth app configured.
57
+
58
+ `optional` now means a secret is not reported as missing. `getMissing()` filtered on "not configured" alone, so a secret whose declaration said absence was supported still showed up on the list of things a deployment had to go and supply — burying the ones that genuinely were. `getAllStatus()` still reports it, flagged `optional`.
59
+
60
+ An OAuth2 secret carries its declaration's `optional` through code generation, to the app credential and to the token store alike: nobody connects without the client id and secret, so a deployment allowed to omit the app is never asked for its tokens either. That branch never looked at the flag before.
61
+
62
+ `credentialOAuthProviders` treats an app secret that resolves `undefined` as unconfigured. An optional secret resolves rather than throws, so the absence arrived past the `catch` that was meant to skip the provider — and `.reveal()` on it threw a `TypeError` that took every `getSession` down with it, which is the exact failure that code exists to prevent.
63
+
1
64
  ## 0.12.113
2
65
 
3
66
  ### Patch Changes
@@ -11,6 +11,7 @@ import { MissingSessionError, ReadonlySessionError } from '../errors/errors.js';
11
11
  import { verifyScopes } from '../scopes.js';
12
12
  import { assertFeatureAvailable } from '../wirings/flag/assert-feature-available.js';
13
13
  import { PikkuCredentialWireService, createWireServicesCredentialWireProps, } from '../services/credential-wire-service.js';
14
+ import { buildCredentialResolutions, credentialOverrideAliases, } from '../wirings/credential/credential-overrides.js';
14
15
  import { defaultPikkuUserIdResolver } from '../services/pikku-user-id.js';
15
16
  import { createInvocationAudit, resolveAuditConfig, } from '../services/audit-service.js';
16
17
  import { createInvocationAnalytics } from '../analytics/analytics.js';
@@ -125,6 +126,7 @@ export const runPikkuFunc = async (wireType, wireId, funcName, { singletonServic
125
126
  const resolvedWire = allChannelMiddleware.length > 0 && wire.channel
126
127
  ? wrapChannelWithMiddleware(wire, resolvedSingletonServices, allChannelMiddleware)
127
128
  : wire;
129
+ const declaredCredentials = pikkuState(funcPackageName ?? null, 'package', 'credentialsMeta');
128
130
  // Set up early so middleware can use setCredential. An addon instance with
129
131
  // credentialOverrides always gets a fresh alias-aware service, even when a
130
132
  // parent credential service is already present on the wire.
@@ -132,12 +134,16 @@ export const runPikkuFunc = async (wireType, wireId, funcName, { singletonServic
132
134
  // Credentials belong to the consuming project, so resolve them via the
133
135
  // project's credentialService (the addon's own singletons may not carry it).
134
136
  const aliasedCredentialWireService = new PikkuCredentialWireService(singletonServices.credentialService ??
135
- resolvedSingletonServices.credentialService, resolvedWire, addonInstance.credentialOverrides);
137
+ resolvedSingletonServices.credentialService, resolvedWire, credentialOverrideAliases(addonInstance.credentialOverrides), {
138
+ resolutions: buildCredentialResolutions(declaredCredentials, addonInstance.credentialOverrides),
139
+ });
136
140
  Object.assign(resolvedWire, createWireServicesCredentialWireProps(aliasedCredentialWireService));
137
141
  }
138
142
  else if (!resolvedWire.getCredentials) {
139
143
  const resolvedCredentialWireService = credentialWireService ??
140
- new PikkuCredentialWireService(resolvedSingletonServices.credentialService, resolvedWire);
144
+ new PikkuCredentialWireService(resolvedSingletonServices.credentialService, resolvedWire, undefined, {
145
+ resolutions: buildCredentialResolutions(declaredCredentials, undefined),
146
+ });
141
147
  Object.assign(resolvedWire, createWireServicesCredentialWireProps(resolvedCredentialWireService));
142
148
  }
143
149
  const resolvedAuditConfig = resolveAuditConfig(funcConfig.audit);
@@ -88,9 +88,14 @@ export const combineMiddleware = (wireType, uid, { wireInheritedMiddleware, wire
88
88
  return middlewareCache[wireType][uid];
89
89
  }
90
90
  const resolved = [];
91
- const globals = pikkuState(packageName, 'middleware', 'global');
92
- if (globals && globals.length > 0) {
93
- resolved.push(...globals);
91
+ // Global middleware is application-wide, so the root namespace always
92
+ // applies — an addon's function still runs inside the host application.
93
+ // See `global-middleware-resolves-from-the-root-namespace-too.md`.
94
+ for (const ns of packageName === null ? [null] : [null, packageName]) {
95
+ const globals = pikkuState(ns, 'middleware', 'global');
96
+ if (globals && globals.length > 0) {
97
+ resolved.push(...globals);
98
+ }
94
99
  }
95
100
  if (wireInheritedMiddleware) {
96
101
  for (const meta of wireInheritedMiddleware) {
@@ -1,13 +1,29 @@
1
1
  import type { CredentialService } from './credential-service.js';
2
2
  import type { PikkuRawWire } from '../types/core.types.js';
3
+ /**
4
+ * Where one credential's value is read from. Decided by what the credential IS
5
+ * — its declared type, as the wiring may have overridden it — and never by
6
+ * whether a per-user lookup came back empty. An absence-driven fallback would
7
+ * let a user who has not connected read the deployment's own token.
8
+ */
9
+ export type CredentialResolution = {
10
+ mode: 'wire';
11
+ } | {
12
+ mode: 'singleton';
13
+ };
14
+ export type CredentialResolutionConfig = {
15
+ /** Keyed by the resolved (post-alias) credential name. */
16
+ resolutions: Record<string, CredentialResolution>;
17
+ };
3
18
  export declare class PikkuCredentialWireService {
4
19
  private credentialService?;
5
20
  private wire?;
6
21
  private aliases?;
22
+ private resolution?;
7
23
  private credentials;
8
24
  private loaded;
9
25
  private loadPromise;
10
- constructor(credentialService?: CredentialService | undefined, wire?: PikkuRawWire | undefined, aliases?: Record<string, string> | undefined);
26
+ constructor(credentialService?: CredentialService | undefined, wire?: PikkuRawWire | undefined, aliases?: Record<string, string> | undefined, resolution?: CredentialResolutionConfig | undefined);
11
27
  private resolveName;
12
28
  /**
13
29
  * A credential is one of the few places vault material is meant to end up, so
@@ -35,6 +51,20 @@ export declare class PikkuCredentialWireService {
35
51
  * nothing to fetch — so they should reach the fast path too.
36
52
  */
37
53
  private doLoad;
54
+ /**
55
+ * The other half of resolving by type. A user's own store can hold a value
56
+ * under a name the wiring made `singleton` — from an earlier wiring, or a
57
+ * connect flow that has since been rewired — and importing it would put the
58
+ * user in charge of a slot the deployment owns. What `set` wrote is left
59
+ * alone: that is the deployment's own path, not the user's.
60
+ */
61
+ private isSingleton;
62
+ /**
63
+ * The `singleton` half of the dispatch. `wire` is absent here on purpose:
64
+ * those values arrive with `getAll(userId)` above, so a wire credential can
65
+ * never pick up a platform value that happens to exist.
66
+ */
67
+ private loadDeploymentCredentials;
38
68
  }
39
69
  export declare function createMiddlewareCredentialWireProps(credentialWire: PikkuCredentialWireService): {
40
70
  setCredential: (name: string, value: unknown) => void;
@@ -4,13 +4,15 @@ export class PikkuCredentialWireService {
4
4
  credentialService;
5
5
  wire;
6
6
  aliases;
7
+ resolution;
7
8
  credentials = {};
8
9
  loaded = false;
9
10
  loadPromise;
10
- constructor(credentialService, wire, aliases) {
11
+ constructor(credentialService, wire, aliases, resolution) {
11
12
  this.credentialService = credentialService;
12
13
  this.wire = wire;
13
14
  this.aliases = aliases;
15
+ this.resolution = resolution;
14
16
  }
15
17
  resolveName(name) {
16
18
  return this.aliases?.[name] ?? name;
@@ -73,23 +75,58 @@ export class PikkuCredentialWireService {
73
75
  */
74
76
  async doLoad() {
75
77
  try {
76
- if (!this.credentialService || !this.wire)
78
+ if (!this.credentialService && !this.resolution)
77
79
  return;
78
- const userId = defaultPikkuUserIdResolver(this.wire);
79
- if (!userId)
80
- return;
81
- this.wire.pikkuUserId = userId;
82
- const allCreds = await this.credentialService.getAll(userId);
83
- for (const [name, value] of Object.entries(allCreds)) {
84
- if (!(name in this.credentials)) {
85
- this.credentials[name] = value;
80
+ const userId = this.wire
81
+ ? defaultPikkuUserIdResolver(this.wire)
82
+ : undefined;
83
+ if (this.credentialService && this.wire && userId) {
84
+ this.wire.pikkuUserId = userId;
85
+ const allCreds = await this.credentialService.getAll(userId);
86
+ for (const [name, value] of Object.entries(allCreds)) {
87
+ if (this.isSingleton(name))
88
+ continue;
89
+ if (!(name in this.credentials)) {
90
+ this.credentials[name] = value;
91
+ }
86
92
  }
87
93
  }
94
+ await this.loadDeploymentCredentials();
88
95
  }
89
96
  finally {
90
97
  this.loaded = true;
91
98
  }
92
99
  }
100
+ /**
101
+ * The other half of resolving by type. A user's own store can hold a value
102
+ * under a name the wiring made `singleton` — from an earlier wiring, or a
103
+ * connect flow that has since been rewired — and importing it would put the
104
+ * user in charge of a slot the deployment owns. What `set` wrote is left
105
+ * alone: that is the deployment's own path, not the user's.
106
+ */
107
+ isSingleton(name) {
108
+ return this.resolution?.resolutions[name]?.mode === 'singleton';
109
+ }
110
+ /**
111
+ * The `singleton` half of the dispatch. `wire` is absent here on purpose:
112
+ * those values arrive with `getAll(userId)` above, so a wire credential can
113
+ * never pick up a platform value that happens to exist.
114
+ */
115
+ async loadDeploymentCredentials() {
116
+ const resolutions = this.resolution?.resolutions;
117
+ if (!resolutions)
118
+ return;
119
+ if (!this.credentialService)
120
+ return;
121
+ for (const [name, resolution] of Object.entries(resolutions)) {
122
+ if (resolution.mode === 'wire' || name in this.credentials)
123
+ continue;
124
+ const value = await this.credentialService.get(name);
125
+ if (value !== null) {
126
+ this.credentials[name] = value;
127
+ }
128
+ }
129
+ }
93
130
  }
94
131
  export function createMiddlewareCredentialWireProps(credentialWire) {
95
132
  return {
@@ -13,6 +13,8 @@ export interface CredentialStatus {
13
13
  name: string;
14
14
  displayName: string;
15
15
  isConfigured: boolean;
16
+ /** Declared `optional: true`, so absence is a supported state rather than a misconfiguration. */
17
+ optional?: boolean;
16
18
  oauth2?: {
17
19
  tokenSecretId: string;
18
20
  };
@@ -42,5 +44,14 @@ export declare class TypedSecretService<TMap = Record<string, unknown>> implemen
42
44
  deleteSecret(key: string): Promise<void>;
43
45
  getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<SecretValues<T>>>;
44
46
  getAllStatus(): Promise<CredentialStatus[]>;
47
+ /**
48
+ * The secrets a deployment still has to supply.
49
+ *
50
+ * An optional one is absent from this list however it is stored: `optional`
51
+ * already declares that absence is supported, which is why `getSecret`
52
+ * resolves `undefined` for it rather than throwing. Reporting it as missing
53
+ * contradicts that, and buries the required secrets someone actually has to
54
+ * go and configure. `getAllStatus` still reports it, flagged.
55
+ */
45
56
  getMissing(): Promise<CredentialStatus[]>;
46
57
  }
@@ -65,13 +65,23 @@ export class TypedSecretService {
65
65
  name: meta.name,
66
66
  displayName: meta.displayName,
67
67
  isConfigured: await this.secrets.hasSecret(secretId),
68
+ optional: meta.optional,
68
69
  oauth2: meta.oauth2,
69
70
  });
70
71
  }
71
72
  return results;
72
73
  }
74
+ /**
75
+ * The secrets a deployment still has to supply.
76
+ *
77
+ * An optional one is absent from this list however it is stored: `optional`
78
+ * already declares that absence is supported, which is why `getSecret`
79
+ * resolves `undefined` for it rather than throwing. Reporting it as missing
80
+ * contradicts that, and buries the required secrets someone actually has to
81
+ * go and configure. `getAllStatus` still reports it, flagged.
82
+ */
73
83
  async getMissing() {
74
84
  const all = await this.getAllStatus();
75
- return all.filter((c) => !c.isConfigured);
85
+ return all.filter((c) => !c.isConfigured && !c.optional);
76
86
  }
77
87
  }
@@ -1,3 +1,4 @@
1
+ import type { CredentialOverrides } from '../wirings/credential/credential-overrides.js';
1
2
  import type { PikkuErrorConstructor, ErrorDetails } from '../errors/error-handler.js';
2
3
  import type { CorePikkuFunctionConfig, CorePermissionGroup, CorePikkuPermission } from '../function/functions.types.js';
3
4
  import type { CorePikkuTriggerFunctionConfig } from '../wirings/trigger/trigger.types.js';
@@ -43,7 +44,7 @@ export interface PikkuPackageState {
43
44
  /** Per-instance name-aliases: logical name the addon reads -> actual project variable name */
44
45
  variableOverrides?: Record<string, string>;
45
46
  /** Per-instance name-aliases: logical name the addon reads -> actual project credential name */
46
- credentialOverrides?: Record<string, string>;
47
+ credentialOverrides?: CredentialOverrides;
47
48
  /** Secrets the host lends this instance, named as the addon reads them */
48
49
  secretGrants?: string[];
49
50
  /** Credentials the host lends this instance, named as the addon reads them */
@@ -1,9 +1,10 @@
1
1
  import type { CoreSingletonServices } from '../../types/core.types.js';
2
+ import type { CredentialOverrides } from '../credential/credential-overrides.js';
2
3
  export type AddonInstance = {
3
4
  namespace: string;
4
5
  secretOverrides?: Record<string, string>;
5
6
  variableOverrides?: Record<string, string>;
6
- credentialOverrides?: Record<string, string>;
7
+ credentialOverrides?: CredentialOverrides;
7
8
  /** Set by the consuming app: secrets it lends this instance, as the addon names them. */
8
9
  secretGrants?: string[];
9
10
  /** Set by the consuming app: credentials it lends this instance, as the addon names them. */
@@ -1,3 +1,4 @@
1
+ import type { CredentialOverrides } from '../credential/credential-overrides.js';
1
2
  import type { CorePikkuMiddleware } from '../../middleware/middleware.types.js';
2
3
  export type WireAddonConfig = {
3
4
  /** How this instance is addressed. One package may be wired more than once, and the name is what tells the instances apart. */
@@ -23,8 +24,12 @@ export type WireAddonConfig = {
23
24
  secretOverrides?: Record<string, string>;
24
25
  /** Points a variable the addon reads at a different key in this deployment. */
25
26
  variableOverrides?: Record<string, string>;
26
- /** Points a credential the addon reads at a different key in this deployment. */
27
- credentialOverrides?: Record<string, string>;
27
+ /**
28
+ * Points a credential the addon reads at a different key in this deployment,
29
+ * and — in the object form — decides whether it is per-user or
30
+ * deployment-wide. An addon declares a default; the wiring decides.
31
+ */
32
+ credentialOverrides?: CredentialOverrides;
28
33
  /** Extra secrets this instance may read, named as the addon reads them — the scope check runs before `secretOverrides` renames them. */
29
34
  secretGrants?: string[];
30
35
  /** Credentials this instance may read on top of the ones it declared. */
@@ -0,0 +1,26 @@
1
+ import type { CredentialResolution } from '../../services/credential-wire-service.js';
2
+ /**
3
+ * How one credential is wired into a deployment. A bare string renames it, as
4
+ * it always has. The object form also decides where its value is read from,
5
+ * which is what lets one addon serve a per-user product and a single team
6
+ * account without the addon author choosing for both.
7
+ */
8
+ export type CredentialOverride = string | {
9
+ /** The key this deployment holds the value under. */
10
+ name?: string;
11
+ /** `wire` is per user, `singleton` is one value for the deployment. */
12
+ mode?: 'singleton' | 'wire';
13
+ };
14
+ export type CredentialOverrides = Record<string, CredentialOverride>;
15
+ /** The rename half, in the `Record<string, string>` shape the alias path wants. */
16
+ export declare const credentialOverrideAliases: (overrides: CredentialOverrides | undefined) => Record<string, string>;
17
+ /**
18
+ * Where each credential the package declared is read from, keyed by the
19
+ * resolved name so the wire service can look it up without re-aliasing.
20
+ *
21
+ * A credential with no override keeps the type its author declared, which is
22
+ * what makes an addon that reads one way work under either wiring.
23
+ */
24
+ export declare const buildCredentialResolutions: (declared: Record<string, {
25
+ type?: string;
26
+ }> | null | undefined, overrides: CredentialOverrides | undefined) => Record<string, CredentialResolution>;
@@ -0,0 +1,45 @@
1
+ /** The rename half, in the `Record<string, string>` shape the alias path wants. */
2
+ export const credentialOverrideAliases = (overrides) => {
3
+ const aliases = {};
4
+ for (const [name, override] of Object.entries(overrides ?? {})) {
5
+ const target = typeof override === 'string' ? override : override.name;
6
+ if (target) {
7
+ aliases[name] = target;
8
+ }
9
+ }
10
+ return aliases;
11
+ };
12
+ /**
13
+ * Where each credential the package declared is read from, keyed by the
14
+ * resolved name so the wire service can look it up without re-aliasing.
15
+ *
16
+ * A credential with no override keeps the type its author declared, which is
17
+ * what makes an addon that reads one way work under either wiring.
18
+ */
19
+ export const buildCredentialResolutions = (declared, overrides) => {
20
+ const resolutions = {};
21
+ const names = new Set([
22
+ ...Object.keys(declared ?? {}),
23
+ ...Object.keys(overrides ?? {}),
24
+ ]);
25
+ /** Which declaration put each resolution there, for the collision message. */
26
+ const claimedBy = {};
27
+ for (const name of names) {
28
+ const override = overrides?.[name];
29
+ const resolved = (typeof override === 'string' ? override : override?.name) ?? name;
30
+ const mode = (typeof override === 'object' ? override.mode : undefined) ??
31
+ declared?.[name]?.type;
32
+ const resolution = mode === 'singleton' ? { mode: 'singleton' } : { mode: 'wire' };
33
+ const existing = resolutions[resolved];
34
+ if (existing && existing.mode !== resolution.mode) {
35
+ throw new Error(`Credentials '${claimedBy[resolved]}' and '${name}' both resolve to '${resolved}' ` +
36
+ `but disagree on how it is read: '${existing.mode}' and '${resolution.mode}'. ` +
37
+ `Only one mode can win, so the loser would silently read the other's ` +
38
+ `value — a per-user credential served from the deployment's own account, ` +
39
+ `or the reverse. Give them separate names, or wire both to the same mode.`);
40
+ }
41
+ resolutions[resolved] = resolution;
42
+ claimedBy[resolved] = name;
43
+ }
44
+ return resolutions;
45
+ };
@@ -1,3 +1,5 @@
1
1
  export { defineCredential } from './define-credential.js';
2
2
  export { validateAndBuildCredentialDefinitionsMeta } from './validate-credential-definitions.js';
3
3
  export type { CoreCredential, CredentialDefinitionsMeta, CredentialDefinitions, } from './credential.types.js';
4
+ export { credentialOverrideAliases, buildCredentialResolutions, } from './credential-overrides.js';
5
+ export type { CredentialOverride, CredentialOverrides, } from './credential-overrides.js';
@@ -1,2 +1,3 @@
1
1
  export { defineCredential } from './define-credential.js';
2
2
  export { validateAndBuildCredentialDefinitionsMeta } from './validate-credential-definitions.js';
3
+ export { credentialOverrideAliases, buildCredentialResolutions, } from './credential-overrides.js';
@@ -1,4 +1,5 @@
1
1
  export { MCPEndpointRegistry } from './mcp-endpoint-registry.js';
2
2
  export { MCPError, wireMCPResource, wireMCPPrompt, runMCPResource, runMCPTool, runMCPPrompt, } from './mcp-runner.js';
3
- export { getMCPResourcesMeta, getMCPToolsMeta, getMCPPromptsMeta, mcpTargetRequiresSession, } from './mcp-runner.js';
3
+ export { getMCPResourcesMeta, getMCPToolsMeta, getMCPPromptsMeta, mcpTargetRequiresSession, mcpEveryTargetRequiresSession, mcpWireName, mcpResolveWireName, } from './mcp-runner.js';
4
+ export type { McpTargetType } from './mcp-runner.js';
4
5
  export type { AssertMCPResourceURIParams, CoreMCPPrompt, CoreMCPResource, MCPPromptResponse, MCPResourceMeta, MCPResourceResponse, MCPToolMeta, MCPToolResponse, MCPPromptMeta, PikkuMCP, } from './mcp.types.js';
@@ -1,3 +1,3 @@
1
1
  export { MCPEndpointRegistry } from './mcp-endpoint-registry.js';
2
2
  export { MCPError, wireMCPResource, wireMCPPrompt, runMCPResource, runMCPTool, runMCPPrompt, } from './mcp-runner.js';
3
- export { getMCPResourcesMeta, getMCPToolsMeta, getMCPPromptsMeta, mcpTargetRequiresSession, } from './mcp-runner.js';
3
+ export { getMCPResourcesMeta, getMCPToolsMeta, getMCPPromptsMeta, mcpTargetRequiresSession, mcpEveryTargetRequiresSession, mcpWireName, mcpResolveWireName, } from './mcp-runner.js';
@@ -47,3 +47,15 @@ export declare const getMCPPromptsMeta: () => import("./mcp.types.js").MCPPrompt
47
47
  * to authenticate its way towards a tool that does not exist.
48
48
  */
49
49
  export declare const mcpTargetRequiresSession: (type: 'tool' | 'resource' | 'prompt', name: string) => boolean;
50
+ /**
51
+ * Whether every registered MCP target needs a session. An empty registry is
52
+ * not "all gated" — there is nothing to gate.
53
+ *
54
+ * See `the-mcp-handshake-is-challenged-only-when-every-target-is-gated.md`.
55
+ */
56
+ export declare const mcpEveryTargetRequiresSession: () => boolean;
57
+ export type McpTargetType = 'tool' | 'resource' | 'prompt';
58
+ /** How a registered target's name is spelled on the wire. */
59
+ export declare const mcpWireName: (type: McpTargetType, name: string) => string;
60
+ /** The registered name a client's wire name refers to. */
61
+ export declare const mcpResolveWireName: (type: McpTargetType, wireName: string) => string;
@@ -202,6 +202,9 @@ export const getMCPPromptsMeta = () => {
202
202
  * to authenticate its way towards a tool that does not exist.
203
203
  */
204
204
  export const mcpTargetRequiresSession = (type, name) => {
205
+ // The transport passes the name exactly as the client sent it, which is the
206
+ // wire spelling — see `mcpWireName`.
207
+ name = mcpResolveWireName(type, name);
205
208
  const meta = type === 'tool'
206
209
  ? pikkuState(null, 'mcp', 'toolsMeta')[name]
207
210
  : type === 'resource'
@@ -225,3 +228,73 @@ export const mcpTargetRequiresSession = (type, name) => {
225
228
  }
226
229
  return !funcMeta.sessionless || funcMeta.auth === true;
227
230
  };
231
+ /**
232
+ * Whether every registered MCP target needs a session. An empty registry is
233
+ * not "all gated" — there is nothing to gate.
234
+ *
235
+ * See `the-mcp-handshake-is-challenged-only-when-every-target-is-gated.md`.
236
+ */
237
+ export const mcpEveryTargetRequiresSession = () => {
238
+ const targets = [
239
+ ['tool', Object.keys(pikkuState(null, 'mcp', 'toolsMeta'))],
240
+ ['resource', Object.keys(pikkuState(null, 'mcp', 'resourcesMeta'))],
241
+ ['prompt', Object.keys(pikkuState(null, 'mcp', 'promptsMeta'))],
242
+ ];
243
+ let seen = 0;
244
+ for (const [type, names] of targets) {
245
+ for (const name of names) {
246
+ seen++;
247
+ if (!mcpTargetRequiresSession(type, name)) {
248
+ return false;
249
+ }
250
+ }
251
+ }
252
+ return seen > 0;
253
+ };
254
+ const mcpMetaFor = (type) => type === 'tool'
255
+ ? pikkuState(null, 'mcp', 'toolsMeta')
256
+ : type === 'resource'
257
+ ? pikkuState(null, 'mcp', 'resourcesMeta')
258
+ : pikkuState(null, 'mcp', 'promptsMeta');
259
+ const WIRE_LEGAL = /^[A-Za-z0-9_-]+$/;
260
+ /**
261
+ * Registered name -> wire name for every target of one type.
262
+ *
263
+ * See `mcp-wire-names-are-assigned-over-the-whole-registry.md`.
264
+ */
265
+ const mcpWireNames = (type) => {
266
+ const names = Object.keys(mcpMetaFor(type)).sort();
267
+ const assigned = new Map();
268
+ const taken = new Set();
269
+ for (const name of names) {
270
+ if (WIRE_LEGAL.test(name)) {
271
+ assigned.set(name, name);
272
+ taken.add(name);
273
+ }
274
+ }
275
+ for (const name of names) {
276
+ if (assigned.has(name)) {
277
+ continue;
278
+ }
279
+ const base = name.replace(/[^A-Za-z0-9_-]/g, '_');
280
+ let candidate = base;
281
+ let n = 2;
282
+ while (taken.has(candidate)) {
283
+ candidate = `${base}_${n++}`;
284
+ }
285
+ assigned.set(name, candidate);
286
+ taken.add(candidate);
287
+ }
288
+ return assigned;
289
+ };
290
+ /** How a registered target's name is spelled on the wire. */
291
+ export const mcpWireName = (type, name) => mcpWireNames(type).get(name) ?? name;
292
+ /** The registered name a client's wire name refers to. */
293
+ export const mcpResolveWireName = (type, wireName) => {
294
+ for (const [name, wire] of mcpWireNames(type)) {
295
+ if (wire === wireName) {
296
+ return name;
297
+ }
298
+ }
299
+ return wireName;
300
+ };
@@ -1,3 +1,4 @@
1
+ import type { CredentialOverrides } from '../credential/credential-overrides.js';
1
2
  import type { PikkuRawWire } from '../../types/core.types.js';
2
3
  import type { AgentInterruptResult } from '../agent/agent-interrupt.js';
3
4
  export type PikkuRPC<Invoke extends (...args: any[]) => any = (...args: any[]) => any, Remote extends (...args: any[]) => any = (...args: any[]) => any, startWorkflow extends (...args: any[]) => any = (...args: any[]) => any, AgentRun extends (...args: any[]) => any = (...args: any[]) => any, AgentStream extends (...args: any[]) => any = (...args: any[]) => any> = {
@@ -47,7 +48,7 @@ export interface ResolvedFunction {
47
48
  rpcEndpoint?: string;
48
49
  secretOverrides?: Record<string, string>;
49
50
  variableOverrides?: Record<string, string>;
50
- credentialOverrides?: Record<string, string>;
51
+ credentialOverrides?: CredentialOverrides;
51
52
  /** Set by the consuming app: secrets it lends this instance, as the addon names them */
52
53
  secretGrants?: string[];
53
54
  /** Set by the consuming app: credentials it lends this instance, as the addon names them */
@@ -0,0 +1,20 @@
1
+ import type { CredentialDefinitions } from '../credential/credential.types.js';
2
+ import type { SecretDefinitions } from './secret.types.js';
3
+ /**
4
+ * The app secrets implied by the OAuth2 credentials a project declares.
5
+ *
6
+ * An OAuth2 credential always needs the app's client id and secret, and that
7
+ * pair is the same shape every time — `OAuth2AppCredential`, which is what the
8
+ * runtime reads it as and what the generated types already say it is. Making
9
+ * every author restate it as a hand-written `defineSecret` bought nothing and
10
+ * was silently skipped often enough that deployments were never asked for
11
+ * credentials their connect flow needed.
12
+ *
13
+ * A declaration that already covers the secret id wins, so an author who wants
14
+ * their own description or `docsUrl` keeps it.
15
+ *
16
+ * The derived secret is optional, matching what every hand-written declaration
17
+ * chose: an addon that also authenticates by API key must still deploy without
18
+ * an OAuth app configured.
19
+ */
20
+ export declare const deriveOAuth2AppSecrets: (credentials: CredentialDefinitions, declared: SecretDefinitions) => SecretDefinitions;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The app secrets implied by the OAuth2 credentials a project declares.
3
+ *
4
+ * An OAuth2 credential always needs the app's client id and secret, and that
5
+ * pair is the same shape every time — `OAuth2AppCredential`, which is what the
6
+ * runtime reads it as and what the generated types already say it is. Making
7
+ * every author restate it as a hand-written `defineSecret` bought nothing and
8
+ * was silently skipped often enough that deployments were never asked for
9
+ * credentials their connect flow needed.
10
+ *
11
+ * A declaration that already covers the secret id wins, so an author who wants
12
+ * their own description or `docsUrl` keeps it.
13
+ *
14
+ * The derived secret is optional, matching what every hand-written declaration
15
+ * chose: an addon that also authenticates by API key must still deploy without
16
+ * an OAuth app configured.
17
+ */
18
+ export const deriveOAuth2AppSecrets = (credentials, declared) => {
19
+ const covered = new Set(declared.map((secret) => secret.secretId));
20
+ const derived = [];
21
+ for (const credential of credentials) {
22
+ const { oauth2 } = credential;
23
+ if (!oauth2)
24
+ continue;
25
+ if (covered.has(oauth2.appCredentialSecretId))
26
+ continue;
27
+ covered.add(oauth2.appCredentialSecretId);
28
+ derived.push({
29
+ name: `${credential.name}App`,
30
+ displayName: `${credential.displayName} OAuth App`,
31
+ description: `OAuth2 app client id and secret for ${credential.displayName}.`,
32
+ secretId: oauth2.appCredentialSecretId,
33
+ optional: true,
34
+ docsUrl: credential.docsUrl,
35
+ oauth2: {
36
+ tokenSecretId: oauth2.tokenSecretId,
37
+ authorizationUrl: oauth2.authorizationUrl,
38
+ tokenUrl: oauth2.tokenUrl,
39
+ scopes: oauth2.scopes,
40
+ pkce: oauth2.pkce,
41
+ additionalParams: oauth2.additionalParams,
42
+ },
43
+ sourceFile: credential.sourceFile,
44
+ });
45
+ }
46
+ return derived;
47
+ };
@@ -1,3 +1,4 @@
1
1
  export { defineSecret } from './secret.types.js';
2
2
  export type { CoreSecret, OAuth2CredentialConfig, SecretDefinitionMeta, SecretDefinitionsMeta, SecretDefinitions, } from './secret.types.js';
3
3
  export { validateAndBuildSecretDefinitionsMeta } from './validate-secret-definitions.js';
4
+ export { deriveOAuth2AppSecrets } from './derive-oauth2-app-secrets.js';
@@ -1,2 +1,3 @@
1
1
  export { defineSecret } from './secret.types.js';
2
2
  export { validateAndBuildSecretDefinitionsMeta } from './validate-secret-definitions.js';
3
+ export { deriveOAuth2AppSecrets } from './derive-oauth2-app-secrets.js';
@@ -0,0 +1,32 @@
1
+ ---
2
+ type: decision
3
+ title: Global middleware resolves from the root namespace too
4
+ description: An addon's dispatch carries the addon's package name, so reading globals from that namespace alone meant the application's own global middleware never ran for it
5
+ tags: [middleware, addons, packages]
6
+ ---
7
+
8
+ # Global middleware resolves from the root namespace too
9
+
10
+ `combineMiddleware` collects global middleware from `[null]` for a root
11
+ dispatch, and from `[null, packageName]` for a dispatch belonging to a package.
12
+
13
+ It previously read one namespace: whichever `packageName` the dispatch carried.
14
+ Application wirings carry `null`, so they were unaffected. A wiring contributed
15
+ by an addon carries that addon's package name, and the application's own globals
16
+ were not in that list — so an addon-contributed MCP tool ran with no session
17
+ middleware at all and refused every caller, while the same function reached over
18
+ HTTP authenticated normally.
19
+
20
+ Global middleware is application-wide by definition, and an addon's function
21
+ still runs inside the host application, so the root namespace always applies.
22
+ One addon's globals still do not reach another addon's dispatches.
23
+
24
+ The consequence is that a global written against the application's services now
25
+ runs where those services may not exist — an addon builds its own. Middleware
26
+ that reaches for a service it was not given has to stand down rather than throw;
27
+ see `a-session-middleware-stands-down-where-it-cannot-authenticate.md` in
28
+ `@pikku/better-auth`, which is where this first surfaced.
29
+
30
+ **What this rules out:** treating a package namespace as a middleware boundary
31
+ in both directions. It is a boundary for what an addon contributes, not a wall
32
+ that the host application's own policy stops at.
@@ -74,6 +74,7 @@ caller is entitled to assume.
74
74
  - [Gateway webhook challenges echo bytes not JSON](gateway-webhook-challenges-echo-bytes-not-json.md) — String verification challenges are returned raw with returnsJSON false, because platforms byte-compare the echo and JSON quoting fails the handshake
75
75
  - [Gateway wiring is a meta-wiring over HTTP and channels](gateway-wiring-is-a-meta-wiring-over-http-and-channels.md) — wireGateway writes handler implementations into the HTTP and channel state directly while the inspector compiles the corresponding meta, so runtime registration deliberately writes no meta
76
76
  - [Generated src paths in pikku meta are absolute](generated-src-paths-in-pikku-meta-are-absolute.md) — emailsMeta.src is resolved by the CLI at generation time, so reading through the project-relative helpers produces a wrong compound path
77
+ - [Global middleware resolves from the root namespace too](global-middleware-resolves-from-the-root-namespace-too.md) — An addon's dispatch carries the addon's package name, so reading globals from that namespace alone meant the application's own global middleware never ran for it
77
78
  - [Hot reload reads the changed source, never a compiled copy of it](hot-reload-reads-the-changed-source-never-a-compiled-copy.md) — A leftover .js beside a .ts made the reloader announce a reload while re-registering the previous implementation; project TypeScript is now compiled by the reloader itself
78
79
  - [Hot reload writes into the function map captured at startup, not pikkuState's current one](hot-reload-writes-into-the-function-map-captured-at-startup.md) — A dev-server watcher may have swapped in a codegen-scoped map whose writes are discarded on restore, and schemas are deliberately left alone
79
80
  - [HTTP request bodies are read once and shared between consumers](http-request-bodies-are-read-once-and-shared.md) — The fetch request wrapper memoises the single-use body and builds web Requests lazily, at the cost of holding the whole body in memory
@@ -87,6 +88,7 @@ caller is entitled to assume.
87
88
  - [In-memory workflow history aliases the live step object](in-memory-workflow-history-aliases-the-live-step-object.md) — stepHistory pushes the same StepState reference that steps holds, so later mutations to a step are visible in its history entry
88
89
  - [Istanbul statement counts attach to the start line only](istanbul-statement-counts-attach-to-the-start-line-only.md) — The istanbul coverage reader credits a statement's hits to its first line, so an enclosing multi-line statement cannot mask an unexecuted inner one
89
90
  - [Local trigger and gateway services assume a single process](local-trigger-and-gateway-services-assume-a-single-process.md) — InMemoryTriggerService and LocalGatewayService start every listener unconditionally with no distributed claiming, so a second instance duplicates every event
91
+ - [MCP wire names are assigned over the whole registry, not derived per name](mcp-wire-names-are-assigned-over-the-whole-registry.md) — Sanitising each name on its own is lossy — `a:b` and `a.b` both become `a_b` — so one of the two targets would be unreachable
90
92
  - [Node-only builtins are imported dynamically](node-only-builtins-are-imported-dynamically.md) — V8CoverageService imports node:inspector inside start() so the module stays loadable on runtimes that have no such builtin
91
93
  - [One project-shape check, called by both validators](one-project-shape-check-two-validators.md) — workspace validate and fabric validate were separate walks over the same project that duplicated sixteen findings verbatim; the shared half now lives in shared-checks.ts and fabric validate is that plus the deploy-shaped checks
92
94
  - [Only functions marked `expose: true` enter a virtual user's catalogue](only-exposed-functions-enter-a-virtual-user-catalogue.md) — Absent is not permissive — an unexposed function 404s over rpc, so offering one spends a step to learn nothing about the product
@@ -111,6 +113,7 @@ caller is entitled to assume.
111
113
  - [The embedding model is pinned per service and doc/query embedding is split](the-embedding-model-is-pinned-per-service-and-doc-query-embedding-is-split.md) — AIEmbeddingService fixes its model at construction so index and query share a vector space, and separates embedDocuments from embedQuery for asymmetric models
112
114
  - [The in-memory workflow service is inline-only and single-process](the-in-memory-workflow-service-is-inline-only-and-single-process.md) — InMemoryWorkflowService wires no queues and implements withRunLock/withStepLock as pass-throughs, because inline execution has no second holder to exclude
113
115
  - [The KEK salt is scoped to the key version, not the secret](the-kek-salt-is-scoped-to-the-key-version.md) — One stored salt per key version means N secrets cost one derivation, which is the point of envelope encryption
116
+ - [The MCP handshake is challenged only when every target is gated](the-mcp-handshake-is-challenged-only-when-every-target-is-gated.md) — A client decides whether a server speaks OAuth from the handshake alone, so a fully-gated server that answers initialize with a 200 is detected as needing no sign-in
114
117
  - [The middleware resolution cache is deliberately unbounded](the-middleware-resolution-cache-is-deliberately-unbounded.md) — Its keyspace is the set of registered wires, not request traffic, and middleware is dynamic — so eviction would buy nothing and cost the dedupe guarantee
115
118
  - [The per-invocation rpc view is a class, because an object literal with a getter is slow to build](the-per-invocation-rpc-view-is-a-class.md) — An accessor declared on an object literal is defined per instance, which drops the literal off V8's fast construction path — measured at 1.15µs against 0.47µs, on every request
116
119
  - [The persona runtime is exported from @pikku/core/persona, never from services](the-persona-runtime-is-exported-from-the-persona-entry-point.md) — Those values reach the actor-flow and agent runners, which no production server runs — and an unbundled deploy loads whatever the import graph names
@@ -0,0 +1,39 @@
1
+ ---
2
+ type: decision
3
+ title: MCP wire names are assigned over the whole registry, not derived per name
4
+ description: Sanitising each name on its own is lossy — `a:b` and `a.b` both become `a_b` — so one of the two targets would be unreachable
5
+ tags: [mcp, naming]
6
+ ---
7
+
8
+ # MCP wire names are assigned over the whole registry
9
+
10
+ MCP clients constrain tool and prompt names to `[A-Za-z0-9_-]`. Pikku's
11
+ namespace separator is `:`, so every target contributed by an addon — `bb2:getMe`
12
+ and friends — carries a name the client cannot accept. Clients drop such names
13
+ from the session rather than fail the connection, which reads as the tools simply
14
+ not existing. The names are therefore rewritten at the transport boundary and
15
+ resolved back on the way in; the namespace itself is untouched, since it is what
16
+ dispatch keys on.
17
+
18
+ The rewrite is computed for **all** names of a target type at once, not for one
19
+ name in isolation. Replacing illegal characters one name at a time is not
20
+ injective: `a:b` and `a.b` both sanitise to `a_b`, so the list handlers would
21
+ advertise one name twice and the resolver could only ever pick one of them. The
22
+ other target becomes unreachable, silently.
23
+
24
+ The assignment runs in two passes over the sorted names:
25
+
26
+ 1. Every name that is already wire-legal claims itself. A target whose name a
27
+ client can already spell keeps that spelling, whatever else is registered.
28
+ 2. The rest are sanitised, and a collision takes the next free `_2`, `_3`, …
29
+ suffix.
30
+
31
+ Sorting makes the result depend only on the set of registered names, so the same
32
+ registry always produces the same wire names, across processes and restarts.
33
+
34
+ **What this rules out:** a pure `wireName(name)` function. Correctness here is a
35
+ property of the whole registry, and a signature that cannot see the registry
36
+ cannot have it. It also rules out percent-style escaping, which would be
37
+ injective but would turn every namespaced tool into something unreadable in the
38
+ client's UI, for a collision that needs two targets differing only in their
39
+ separator.
@@ -0,0 +1,29 @@
1
+ ---
2
+ type: decision
3
+ title: The MCP handshake is challenged only when every target is gated
4
+ description: A client decides whether a server speaks OAuth from the handshake alone, so a fully-gated server that answers initialize with a 200 is detected as needing no sign-in
5
+ tags: [mcp, auth, oauth]
6
+ ---
7
+
8
+ # The MCP handshake is challenged only when every target is gated
9
+
10
+ `requestNeedsCredentials` challenges `initialize` when
11
+ `mcpEveryTargetRequiresSession()` is true, and only then.
12
+
13
+ A client decides at connection time whether a server speaks OAuth, and the only
14
+ thing it can decide from is whether the handshake came back `401` with a
15
+ `WWW-Authenticate` header naming the resource metadata. A server that answers
16
+ `initialize` with a `200` and then `401`s every single tool call has told the
17
+ client "no sign-in needed" and then refused it everything. That is how a fully
18
+ gated server ends up shown as open, with no way for the user to sign in — the
19
+ client never offers the option, because it was told there was nothing to offer.
20
+
21
+ The condition is deliberately all-or-nothing. A server with even one open target
22
+ genuinely is usable anonymously, and challenging its handshake would lock out
23
+ clients that have no credentials to offer at all. An empty registry is not "all
24
+ gated" either: there is nothing there to gate, so the handshake stays open.
25
+
26
+ **What this rules out:** challenging the handshake whenever _any_ target needs a
27
+ session. The handshake is not the place to express per-target policy — the
28
+ per-target `401` already does that, and a client that has been let in can act on
29
+ it. The handshake only answers "is there a sign-in here at all".
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.113",
3
+ "version": "0.12.114",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",
@@ -216,7 +216,10 @@
216
216
  "getMCPPromptsMeta",
217
217
  "getMCPResourcesMeta",
218
218
  "getMCPToolsMeta",
219
+ "mcpEveryTargetRequiresSession",
220
+ "mcpResolveWireName",
219
221
  "mcpTargetRequiresSession",
222
+ "mcpWireName",
220
223
  "runMCPPrompt",
221
224
  "runMCPResource",
222
225
  "runMCPTool",
@@ -304,6 +307,8 @@
304
307
  ],
305
308
  "./node": [],
306
309
  "./credential": [
310
+ "buildCredentialResolutions",
311
+ "credentialOverrideAliases",
307
312
  "defineCredential",
308
313
  "validateAndBuildCredentialDefinitionsMeta"
309
314
  ],
@@ -351,6 +356,7 @@
351
356
  ],
352
357
  "./secret": [
353
358
  "defineSecret",
359
+ "deriveOAuth2AppSecrets",
354
360
  "validateAndBuildSecretDefinitionsMeta"
355
361
  ],
356
362
  "./variable": [