@pikku/core 0.12.112 → 0.12.113
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/CHANGELOG.md +74 -0
- package/dist/wirings/addon/wire-addon.d.ts +7 -2
- package/dist/wirings/cli/index.d.ts +6 -0
- package/dist/wirings/cli/index.js +6 -0
- package/dist/wirings/http/http-routes.js +1 -0
- package/dist/wirings/http/http-runner.js +5 -5
- package/dist/wirings/http/http-stream-protocol.d.ts +12 -0
- package/dist/wirings/http/http-stream-protocol.js +16 -0
- package/dist/wirings/http/http.types.d.ts +35 -0
- package/dist/wirings/http/index.d.ts +1 -1
- package/dist/wirings/mcp/index.d.ts +1 -1
- package/dist/wirings/mcp/index.js +1 -1
- package/dist/wirings/mcp/mcp-runner.d.ts +15 -0
- package/dist/wirings/mcp/mcp-runner.js +38 -0
- package/knowledge/decisions/security/an-mcp-refusal-is-decided-before-dispatch.md +48 -0
- package/knowledge/decisions/security/an-oauth2-credential-is-read-by-its-account-row.md +40 -0
- package/knowledge/decisions/security/index.md +2 -0
- package/package.json +1 -1
- package/src/public-surface.json +15 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,77 @@
|
|
|
1
|
+
## 0.12.113
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- c842054: A pikku command never ends in a node internals warning, and a secret can be set from a script.
|
|
6
|
+
|
|
7
|
+
`pikku fabric secrets set NAME` prompted for the value through readline in terminal mode. On a stdin that is not a tty that promise never settles at all, so the command printed `BETTER_AUTH_SECRET value:` and then node's "Detected unsettled top-level await", naming a line of `@pikku/cli`'s own bin — under bun it simply hung. The prompt now reads the first line of stdin when there is no tty, so `echo '<value>' | pikku fabric secrets set NAME` works, and refuses with the flag to reach for (`--value`) when stdin is closed or empty. `promptConfirm` gained the same backstop, so a caller that forgets its `isTTY` gate gets a refusal instead of a hang.
|
|
8
|
+
|
|
9
|
+
Alongside it, the places a raw stack could still reach a user:
|
|
10
|
+
|
|
11
|
+
- The `pikku` binary formats through `formatCLIError` instead of printing `error.message`, and installs `uncaughtException` / `unhandledRejection` handlers so nothing escaping a listener or a floating promise is dumped unformatted. A `CLIError` the runner already printed is no longer printed twice.
|
|
12
|
+
- The generated local and channel CLI bootstraps do the same, rather than `console.error('Fatal error:', error.message)` — which dropped the stack even when one was asked for.
|
|
13
|
+
- A missing `pikku.config.json` says where it looked and what to do, as a `PikkuCLIConfigError`, which is now a `PikkuError` along with `GitError` and every remaining plain `Error` raised by a `pikku fabric` command. A directory-wide test keeps it that way.
|
|
14
|
+
- `pikku dev`'s watcher and the MCP schema loader log their causes through the logger at debug level instead of `console.error(err)` over the top of the output.
|
|
15
|
+
|
|
16
|
+
Stacks are unchanged where they are the answer: an unexpected error still keeps its frames, and `--verbose` / `PIKKU_DEBUG` still prints the stack for a deliberate one. `formatCLIError` and `wantsStackTrace` are exported from `@pikku/core/cli` so every entrypoint that can be the last thing to catch an error prints it the same way.
|
|
17
|
+
|
|
18
|
+
- dfd7019: An MCP tool reads the claims its host verified
|
|
19
|
+
|
|
20
|
+
`PikkuHTTP` gains an `authInfo` of the new `PikkuHTTPAuthInfo`: the token,
|
|
21
|
+
client and scopes a transport that already verified a bearer token hands on.
|
|
22
|
+
It is strictly pass-through — nothing in pikku derives it from a request's own
|
|
23
|
+
headers, because verifying a token is the host's job. A function could always
|
|
24
|
+
read the `Authorization` header itself; what this adds is what the raw header
|
|
25
|
+
cannot carry.
|
|
26
|
+
|
|
27
|
+
`PikkuMCPServer`'s server factory now carries the SDK's `authInfo` onto the
|
|
28
|
+
wire beside the request, so the `authInfo` a host passes to
|
|
29
|
+
`createFetchHandler` reaches the tool rather than stopping at the SDK.
|
|
30
|
+
|
|
31
|
+
`pikkuCredentialOAuth` also names itself when it provisions the platform user.
|
|
32
|
+
Every other pikku plugin passes a source to `internalAdapter.createUser`, and
|
|
33
|
+
better-auth refuses a `user.validateUserInfo` gate that is handed none — so an
|
|
34
|
+
app with that hook configured could not link a singleton credential at all.
|
|
35
|
+
|
|
36
|
+
- dfd7019: An MCP call that needs a session is refused with an OAuth challenge
|
|
37
|
+
|
|
38
|
+
A tool fronting a session-requiring function used to answer an unauthenticated
|
|
39
|
+
caller with `200` and `isError: true`, which a client reads as a tool that
|
|
40
|
+
broke rather than one it has not authenticated for — so OAuth discovery never
|
|
41
|
+
began. Such a call now gets `401` with a `WWW-Authenticate: Bearer` challenge
|
|
42
|
+
naming the resource metadata, and `/.well-known/oauth-protected-resource` is
|
|
43
|
+
served alongside the MCP endpoint.
|
|
44
|
+
|
|
45
|
+
The endpoint is not gated as a whole. `mcpTargetRequiresSession` reads the
|
|
46
|
+
declarations the runner already enforces — a `pikkuFunc` needs a session, a
|
|
47
|
+
`pikkuSessionlessFunc` needs one only where it says `auth: true` — so public
|
|
48
|
+
and private tools can share one server and only the private ones are
|
|
49
|
+
challenged.
|
|
50
|
+
|
|
51
|
+
`createFetchHandler` and `createHTTPRequestHandler` take an optional `auth`
|
|
52
|
+
describing what to advertise (`authorizationServers`, `scopesSupported`,
|
|
53
|
+
`resourceName`), surfaced on both servers as an `mcpAuth` option. Every field
|
|
54
|
+
defaults from the request, because a pikku app is usually its own
|
|
55
|
+
authorization server. Both handlers now also return `ownsPath`, because the
|
|
56
|
+
discovery document lives outside `mcpPath` and a host routing on the endpoint
|
|
57
|
+
alone would 404 the document its own challenge points at.
|
|
58
|
+
|
|
59
|
+
- 9b978e7: A failed SSE stream reports the error in the protocol its client is parsing
|
|
60
|
+
|
|
61
|
+
An SSE route can now declare `streamProtocol: 'agui'`, and the generated agent
|
|
62
|
+
stream and resume routes do. A function that throws mid-stream then ends the
|
|
63
|
+
stream with a single AG-UI `RUN_ERROR` instead of Pikku's `error`/`done` frames,
|
|
64
|
+
which an AG-UI client could only surface as a Zod parse failure with the real
|
|
65
|
+
message nowhere in sight.
|
|
66
|
+
|
|
67
|
+
- 1469e73: The app names which of an addon's functions reach MCP
|
|
68
|
+
|
|
69
|
+
`wireAddon`'s `mcp` takes a list as well as `true`. `true` still offers every
|
|
70
|
+
function the addon declared `mcp: true`; a list names the tools this deployment
|
|
71
|
+
offers, whether or not the addon declared them, and is typed against the
|
|
72
|
+
function names that addon publishes — a typo is a compile error rather than a
|
|
73
|
+
tool silently missing from the menu.
|
|
74
|
+
|
|
1
75
|
## 0.12.112
|
|
2
76
|
|
|
3
77
|
### Patch Changes
|
|
@@ -8,8 +8,13 @@ export type WireAddonConfig = {
|
|
|
8
8
|
rpcEndpoint?: string;
|
|
9
9
|
/** Requires a session for every function in the addon, whatever each one declares. Gates an addon whose functions are individually open. */
|
|
10
10
|
auth?: boolean;
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Offers the addon's functions to MCP clients as tools, without wiring each
|
|
13
|
+
* one. `true` offers every function the addon itself declared `mcp: true`; a
|
|
14
|
+
* list names the functions to offer, whether or not the addon declared them,
|
|
15
|
+
* and is typed against the addon's function names.
|
|
16
|
+
*/
|
|
17
|
+
mcp?: boolean | string[];
|
|
13
18
|
/** Filters this addon in and out of a build — see the `tags` option on `pikku all`. It has no effect at runtime. */
|
|
14
19
|
tags?: string[];
|
|
15
20
|
/** Required of every function in the addon, on top of the function's own. */
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
export { wireCLI, runCLICommand, pikkuCLIRender, executeCLI, CLIError, } from './cli-runner.js';
|
|
2
2
|
export { parseCLIArguments, generateCommandHelp } from './command-parser.js';
|
|
3
3
|
export { defineCLICommands } from './define-cli-commands.js';
|
|
4
|
+
/**
|
|
5
|
+
* Exported so every entrypoint that can be the last thing to catch an error —
|
|
6
|
+
* the `pikku` binary, a generated bootstrap, a channel client — prints it the
|
|
7
|
+
* same way, instead of each inventing its own `console.error(error.message)`.
|
|
8
|
+
*/
|
|
9
|
+
export { formatCLIError, wantsStackTrace } from './format-cli-error.js';
|
|
4
10
|
export type { CLIMeta, CLICommandMeta, CLIProgramMeta, CoreCLI, CoreCLICommandConfig, CorePikkuCLIRender, } from './cli.types.js';
|
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
export { wireCLI, runCLICommand, pikkuCLIRender, executeCLI, CLIError, } from './cli-runner.js';
|
|
2
2
|
export { parseCLIArguments, generateCommandHelp } from './command-parser.js';
|
|
3
3
|
export { defineCLICommands } from './define-cli-commands.js';
|
|
4
|
+
/**
|
|
5
|
+
* Exported so every entrypoint that can be the last thing to catch an error —
|
|
6
|
+
* the `pikku` binary, a generated bootstrap, a channel client — prints it the
|
|
7
|
+
* same way, instead of each inventing its own `console.error(error.message)`.
|
|
8
|
+
*/
|
|
9
|
+
export { formatCLIError, wantsStackTrace } from './format-cli-error.js';
|
|
@@ -54,6 +54,7 @@ function registerRoute(route, groupConfig) {
|
|
|
54
54
|
timeout: route.timeout,
|
|
55
55
|
headers: route.headers,
|
|
56
56
|
sse: route.sse,
|
|
57
|
+
streamProtocol: route.streamProtocol,
|
|
57
58
|
// `CoreHTTPFunctionWiring` is discriminated by `method`, and a group builds
|
|
58
59
|
// its routes from a method chosen at runtime — no arm can be narrowed to.
|
|
59
60
|
});
|
|
@@ -6,6 +6,7 @@ import { getErrorResponse } from '../../errors/error-handler.js';
|
|
|
6
6
|
import { handleHTTPError } from '../../handle-error.js';
|
|
7
7
|
import { isProduction } from '../../env.js';
|
|
8
8
|
import { pikkuState } from '../../pikku-state.js';
|
|
9
|
+
import { streamErrorFrames } from './http-stream-protocol.js';
|
|
9
10
|
import { PikkuFetchHTTPResponse } from './pikku-fetch-http-response.js';
|
|
10
11
|
import { PikkuFetchHTTPRequest } from './pikku-fetch-http-request.js';
|
|
11
12
|
// The leaf module, not the channel-rpc barrel: http needs one refusal
|
|
@@ -194,11 +195,10 @@ const executeRoute = async (services, matchedRoute, http, options) => {
|
|
|
194
195
|
singletonServices.logger.error(e instanceof Error ? e.message : e);
|
|
195
196
|
try {
|
|
196
197
|
const errorResponse = getErrorResponse(e);
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
http?.response?.arrayBuffer(JSON.stringify({ type: 'done' }));
|
|
198
|
+
const message = errorResponse?.message ?? 'Internal server error';
|
|
199
|
+
for (const frame of streamErrorFrames(matchedRoute.route.streamProtocol, message)) {
|
|
200
|
+
http?.response?.arrayBuffer(JSON.stringify(frame));
|
|
201
|
+
}
|
|
202
202
|
}
|
|
203
203
|
catch { }
|
|
204
204
|
channel?.close();
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { HTTPStreamProtocol } from './http.types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The frames that terminate a stream whose function threw after the response
|
|
4
|
+
* was already committed to streaming.
|
|
5
|
+
*
|
|
6
|
+
* A stream's consumer parses every frame against the protocol it was promised,
|
|
7
|
+
* so an error announced in the wrong one reaches it as a parser failure with
|
|
8
|
+
* the actual message nowhere in sight. AG-UI accepts `RUN_ERROR` as the first
|
|
9
|
+
* event as readily as the last, and forbids anything after it, so a failure
|
|
10
|
+
* there is that one frame — never a trailing `done`.
|
|
11
|
+
*/
|
|
12
|
+
export declare const streamErrorFrames: (protocol: HTTPStreamProtocol | undefined, message: string) => Array<Record<string, unknown>>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frames that terminate a stream whose function threw after the response
|
|
3
|
+
* was already committed to streaming.
|
|
4
|
+
*
|
|
5
|
+
* A stream's consumer parses every frame against the protocol it was promised,
|
|
6
|
+
* so an error announced in the wrong one reaches it as a parser failure with
|
|
7
|
+
* the actual message nowhere in sight. AG-UI accepts `RUN_ERROR` as the first
|
|
8
|
+
* event as readily as the last, and forbids anything after it, so a failure
|
|
9
|
+
* there is that one frame — never a trailing `done`.
|
|
10
|
+
*/
|
|
11
|
+
export const streamErrorFrames = (protocol, message) => {
|
|
12
|
+
if (protocol === 'agui') {
|
|
13
|
+
return [{ type: 'RUN_ERROR', message }];
|
|
14
|
+
}
|
|
15
|
+
return [{ type: 'error', errorText: message }, { type: 'done' }];
|
|
16
|
+
};
|
|
@@ -40,9 +40,33 @@ export type CoreHTTPFunction = HTTPRouteBaseConfig & {
|
|
|
40
40
|
/** Sends the returned value as-is rather than JSON-encoding it, for a route whose body is binary or already serialised. */
|
|
41
41
|
returnsJSON?: false;
|
|
42
42
|
};
|
|
43
|
+
/**
|
|
44
|
+
* The claims a transport that already verified a bearer token hands on.
|
|
45
|
+
*
|
|
46
|
+
* Strictly pass-through: nothing in pikku derives this from a request's own
|
|
47
|
+
* headers, because verifying a token is the host's job, not the wire's. A
|
|
48
|
+
* function can always read the `Authorization` header itself — what this adds
|
|
49
|
+
* is what the raw header cannot carry, the scopes and client the token was
|
|
50
|
+
* actually issued for.
|
|
51
|
+
*
|
|
52
|
+
* Shaped to match the MCP SDK's `AuthInfo`, which is the one caller that
|
|
53
|
+
* populates it today.
|
|
54
|
+
*/
|
|
55
|
+
export interface PikkuHTTPAuthInfo {
|
|
56
|
+
token: string;
|
|
57
|
+
clientId: string;
|
|
58
|
+
scopes: string[];
|
|
59
|
+
/** Seconds since the epoch. */
|
|
60
|
+
expiresAt?: number;
|
|
61
|
+
/** The RFC 8707 resource server this token is valid for. */
|
|
62
|
+
resource?: URL;
|
|
63
|
+
extra?: Record<string, unknown>;
|
|
64
|
+
}
|
|
43
65
|
export interface PikkuHTTP<In = unknown> {
|
|
44
66
|
request?: PikkuHTTPRequest<In>;
|
|
45
67
|
response?: PikkuHTTPResponse;
|
|
68
|
+
/** Verified token claims, when the transport was handed them. */
|
|
69
|
+
authInfo?: PikkuHTTPAuthInfo;
|
|
46
70
|
}
|
|
47
71
|
export type PikkuQuery<T = Record<string, string | undefined>> = Record<string, string | T | null | Array<T | null>>;
|
|
48
72
|
/**
|
|
@@ -78,6 +102,13 @@ type HTTPWiringAuth<In, Out, PikkuFunction extends CorePikkuFunction<In, Out, an
|
|
|
78
102
|
/** On an open route there is no session, so this must be a sessionless function. */
|
|
79
103
|
func: CorePikkuFunctionConfig<PikkuFunctionSessionless, PikkuPermission, PikkuMiddleware>;
|
|
80
104
|
};
|
|
105
|
+
/**
|
|
106
|
+
* The event protocol a streaming route's frames follow, so the layer that
|
|
107
|
+
* terminates a failed stream can speak the same one the client is parsing.
|
|
108
|
+
* `'pikku'` frames are `{ type: 'error' | 'done' }`; `'agui'` frames are
|
|
109
|
+
* AG-UI events, where a failure is a single `RUN_ERROR`.
|
|
110
|
+
*/
|
|
111
|
+
export type HTTPStreamProtocol = 'pikku' | 'agui';
|
|
81
112
|
/**
|
|
82
113
|
* `sse` and `query` are each valid on one method only, so the method carries
|
|
83
114
|
* them: streaming is a GET, and naming which input keys arrive in the query
|
|
@@ -92,6 +123,8 @@ type HTTPWiringMethod<In> = {
|
|
|
92
123
|
method: 'get';
|
|
93
124
|
/** Streams the response as server-sent events instead of returning it once. GET only. */
|
|
94
125
|
sse?: boolean;
|
|
126
|
+
/** Which event protocol the frames on this stream follow. Defaults to `'pikku'`. */
|
|
127
|
+
streamProtocol?: HTTPStreamProtocol;
|
|
95
128
|
} | {
|
|
96
129
|
/** The HTTP method. A route and method together address one wiring. */
|
|
97
130
|
method: 'post';
|
|
@@ -162,6 +195,8 @@ export type HTTPRouteConfig<PikkuFunction extends CorePikkuFunction<any, any, an
|
|
|
162
195
|
auth?: boolean;
|
|
163
196
|
middleware?: PikkuMiddleware[];
|
|
164
197
|
sse?: boolean;
|
|
198
|
+
/** Which event protocol the frames on this stream follow. Defaults to `'pikku'`. */
|
|
199
|
+
streamProtocol?: HTTPStreamProtocol;
|
|
165
200
|
};
|
|
166
201
|
export type HTTPRoutesGroupConfig<PikkuPermission extends CorePikkuPermission<any, any, any> = CorePikkuPermission<any, any, any>, PikkuMiddleware extends CorePikkuMiddleware<any, any> = CorePikkuMiddleware<any, any>> = {
|
|
167
202
|
basePath?: string;
|
|
@@ -5,4 +5,4 @@ export { fetch, fetchData, wireHTTP, addHTTPMiddleware } from './http-runner.js'
|
|
|
5
5
|
export { wireHTTPRoutes, defineHTTPRoutes } from './http-routes.js';
|
|
6
6
|
export { toWebRequest, applyWebResponse } from './web-request.js';
|
|
7
7
|
export { httpRouter } from './routers/http-router.js';
|
|
8
|
-
export type { AssertHTTPWiringParams, CoreHTTPFunctionWiring, HTTPMethod, HTTPRouteBaseConfig, HTTPRouteContract, HTTPRouteMap, HTTPWiringsMeta, PikkuHTTP, PikkuHTTPRequest, PikkuHTTPResponse, PikkuQuery, RunHTTPWiringOptions, } from './http.types.js';
|
|
8
|
+
export type { AssertHTTPWiringParams, CoreHTTPFunctionWiring, HTTPMethod, HTTPRouteBaseConfig, HTTPRouteContract, HTTPRouteMap, HTTPWiringsMeta, PikkuHTTP, PikkuHTTPAuthInfo, PikkuHTTPRequest, PikkuHTTPResponse, PikkuQuery, RunHTTPWiringOptions, } from './http.types.js';
|
|
@@ -1,4 +1,4 @@
|
|
|
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, } from './mcp-runner.js';
|
|
3
|
+
export { getMCPResourcesMeta, getMCPToolsMeta, getMCPPromptsMeta, mcpTargetRequiresSession, } from './mcp-runner.js';
|
|
4
4
|
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, } from './mcp-runner.js';
|
|
3
|
+
export { getMCPResourcesMeta, getMCPToolsMeta, getMCPPromptsMeta, mcpTargetRequiresSession, } from './mcp-runner.js';
|
|
@@ -32,3 +32,18 @@ export declare function runMCPPrompt(request: JsonRpcRequest, params: RunMCPEndp
|
|
|
32
32
|
export declare const getMCPResourcesMeta: () => import("./mcp.types.js").MCPResourceMeta;
|
|
33
33
|
export declare const getMCPToolsMeta: () => import("./mcp.types.js").MCPToolMeta;
|
|
34
34
|
export declare const getMCPPromptsMeta: () => import("./mcp.types.js").MCPPromptMeta;
|
|
35
|
+
/**
|
|
36
|
+
* Whether a call to this MCP target would need a session to run.
|
|
37
|
+
*
|
|
38
|
+
* Read from the same declarations the runner enforces: a `pikkuFunc` always
|
|
39
|
+
* needs one, and a `pikkuSessionlessFunc` needs one only where it says
|
|
40
|
+
* `auth: true`. A transport asks this to answer an unauthenticated call with a
|
|
41
|
+
* `401` challenge instead of dispatching it — the status and the
|
|
42
|
+
* `WWW-Authenticate` header have to be chosen before the response starts, which
|
|
43
|
+
* is earlier than the refusal itself can be known.
|
|
44
|
+
*
|
|
45
|
+
* Unknown targets are treated as open: a name nobody registered is a
|
|
46
|
+
* `Method not found`, and answering it with a challenge would invite a client
|
|
47
|
+
* to authenticate its way towards a tool that does not exist.
|
|
48
|
+
*/
|
|
49
|
+
export declare const mcpTargetRequiresSession: (type: 'tool' | 'resource' | 'prompt', name: string) => boolean;
|
|
@@ -187,3 +187,41 @@ export const getMCPToolsMeta = () => {
|
|
|
187
187
|
export const getMCPPromptsMeta = () => {
|
|
188
188
|
return pikkuState(null, 'mcp', 'promptsMeta');
|
|
189
189
|
};
|
|
190
|
+
/**
|
|
191
|
+
* Whether a call to this MCP target would need a session to run.
|
|
192
|
+
*
|
|
193
|
+
* Read from the same declarations the runner enforces: a `pikkuFunc` always
|
|
194
|
+
* needs one, and a `pikkuSessionlessFunc` needs one only where it says
|
|
195
|
+
* `auth: true`. A transport asks this to answer an unauthenticated call with a
|
|
196
|
+
* `401` challenge instead of dispatching it — the status and the
|
|
197
|
+
* `WWW-Authenticate` header have to be chosen before the response starts, which
|
|
198
|
+
* is earlier than the refusal itself can be known.
|
|
199
|
+
*
|
|
200
|
+
* Unknown targets are treated as open: a name nobody registered is a
|
|
201
|
+
* `Method not found`, and answering it with a challenge would invite a client
|
|
202
|
+
* to authenticate its way towards a tool that does not exist.
|
|
203
|
+
*/
|
|
204
|
+
export const mcpTargetRequiresSession = (type, name) => {
|
|
205
|
+
const meta = type === 'tool'
|
|
206
|
+
? pikkuState(null, 'mcp', 'toolsMeta')[name]
|
|
207
|
+
: type === 'resource'
|
|
208
|
+
? pikkuState(null, 'mcp', 'resourcesMeta')[name]
|
|
209
|
+
: pikkuState(null, 'mcp', 'promptsMeta')[name];
|
|
210
|
+
if (!meta) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
let funcName = meta.pikkuFuncId;
|
|
214
|
+
let packageName = null;
|
|
215
|
+
if (funcName.includes(':')) {
|
|
216
|
+
const resolved = resolveNamespace(funcName);
|
|
217
|
+
if (resolved) {
|
|
218
|
+
funcName = resolved.function;
|
|
219
|
+
packageName = resolved.package;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const funcMeta = pikkuState(packageName, 'function', 'meta')[funcName];
|
|
223
|
+
if (!funcMeta) {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
return !funcMeta.sessionless || funcMeta.auth === true;
|
|
227
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: An MCP refusal is decided before dispatch, not raised from the tool
|
|
4
|
+
description: mcpTargetRequiresSession lets the transport answer 401 with an OAuth challenge, because the MCP response streams and the status is gone by the time the runner refuses
|
|
5
|
+
tags: mcp, permissions, transport
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# An MCP refusal is decided before dispatch, not raised from the tool
|
|
9
|
+
|
|
10
|
+
An MCP client discovers how to authenticate from a `401` carrying a
|
|
11
|
+
`WWW-Authenticate` challenge — that header names the RFC 9728 Protected Resource
|
|
12
|
+
Metadata document, which names the authorization server, which is where OAuth
|
|
13
|
+
starts. Delivered any other way, the refusal is not a refusal: a `MissingSessionError`
|
|
14
|
+
flattened into a JSON-RPC result arrives as `200` with `isError: true`, which a
|
|
15
|
+
client reads as a tool that failed, and no discovery happens.
|
|
16
|
+
|
|
17
|
+
The obvious implementation — catch the refusal in `tools/call` and set the status
|
|
18
|
+
afterwards — cannot work, and not for a reason a test makes obvious. The MCP HTTP
|
|
19
|
+
response **streams**: the entry returns a `Response` whose body the transport is
|
|
20
|
+
still writing when the JSON-RPC handler runs. Instrumenting both ends shows the
|
|
21
|
+
entry observing its own flag as unset *before* the tool ever refuses. Status and
|
|
22
|
+
headers are chosen at the top of the response; by the time anything knows a
|
|
23
|
+
session was missing, they are already on the wire.
|
|
24
|
+
|
|
25
|
+
So the decision moves earlier, to the only two things knowable before dispatch:
|
|
26
|
+
which target the request body names, and whether the request presents anything to
|
|
27
|
+
authenticate with. `mcpTargetRequiresSession` answers the first from the same
|
|
28
|
+
declarations the runner enforces — a `pikkuFunc` always needs a session, a
|
|
29
|
+
`pikkuSessionlessFunc` needs one only where it says `auth: true` — so the
|
|
30
|
+
transport's answer and the runner's cannot disagree. An unregistered name is
|
|
31
|
+
treated as open, because a name nobody registered is a `Method not found`, and
|
|
32
|
+
challenging it invites a client to authenticate its way towards a tool that does
|
|
33
|
+
not exist.
|
|
34
|
+
|
|
35
|
+
This is what makes a single endpoint serve public and private tools together,
|
|
36
|
+
which the MCP spec allows and pikku's per-function `auth` flag already describes.
|
|
37
|
+
`tools/list` stays ungated, so a client can see the menu before it has a token.
|
|
38
|
+
|
|
39
|
+
**What this rules out:** verifying tokens in the transport. Only a request with
|
|
40
|
+
*no* credentials is challenged; a present-but-expired token is dispatched and its
|
|
41
|
+
refusal reaches the client as a tool error. Doing better means resolving the
|
|
42
|
+
session before dispatch, which runs the app's middleware twice per call and puts
|
|
43
|
+
the transport in the business of second-guessing the middleware that owns session
|
|
44
|
+
resolution. The cheaper half — no credentials at all — covers the case that
|
|
45
|
+
actually matters, because that is the state every client starts in.
|
|
46
|
+
|
|
47
|
+
Also ruled out: gating the endpoint as a whole with `requireBearerAuth`. It would
|
|
48
|
+
work, and it would make every public tool private, which is a different product.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: An OAuth2 credential is read by its account row, not by its provider name
|
|
4
|
+
description: better-auth 1.7.5 selects an account by row id under a strict body schema, so BetterAuthCredentialService resolves the row through the internal adapter before asking for a token
|
|
5
|
+
tags: better-auth, credentials, oauth2
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# An OAuth2 credential is read by its account row, not by its provider name
|
|
9
|
+
|
|
10
|
+
A pikku OAuth2 credential is named — `user-oauth`, `company-slack` — and that
|
|
11
|
+
name is the better-auth `providerId` its account row carries. Reading the
|
|
12
|
+
credential used to mean handing better-auth the name:
|
|
13
|
+
`getAccessToken({ body: { providerId, userId } })`.
|
|
14
|
+
|
|
15
|
+
better-auth 1.7.5 stopped accepting that. `get-access-token` and
|
|
16
|
+
`unlink-account` both take a strict union selecting the account by its **own row
|
|
17
|
+
id**, so a provider name is not merely ignored, it is rejected — `[body] Invalid
|
|
18
|
+
input`, a 500 on every read of a linked credential: a tool's token, an agent's,
|
|
19
|
+
the console's status card. An agent that cannot resolve its credential never
|
|
20
|
+
makes a model call, so the failure surfaces far from its cause.
|
|
21
|
+
|
|
22
|
+
So the name is resolved to a row first, through
|
|
23
|
+
`context.internalAdapter.findAccounts(userId)` — the same lookup the link
|
|
24
|
+
callback writes the row with and `unlinkAccount` already used. That is also the
|
|
25
|
+
only account lookup that takes a userId at all: `listUserAccounts` resolves the
|
|
26
|
+
*caller's session* and throws UNAUTHORIZED server-side, which is no use for the
|
|
27
|
+
two revocations that have no session — a platform credential, whose owner never
|
|
28
|
+
signs in, and an admin acting on someone else's.
|
|
29
|
+
|
|
30
|
+
A useful consequence: an unlinked provider is now answered before better-auth is
|
|
31
|
+
asked, rather than by catching its `ACCOUNT_NOT_FOUND`. The catch stays, because
|
|
32
|
+
a failed refresh must still not read as "not connected yet" — that would show a
|
|
33
|
+
connect button for an account that is linked but broken.
|
|
34
|
+
|
|
35
|
+
**What this rules out:** treating `providerId` as a credential's address
|
|
36
|
+
anywhere a token is read or revoked. It remains the credential's *name* — what
|
|
37
|
+
an app declares and what the link flow writes — but the row id is what
|
|
38
|
+
better-auth is spoken to in. A fake of better-auth that keys tokens by provider
|
|
39
|
+
name will pass while the real thing rejects every call, which is how this
|
|
40
|
+
survived a bump with 222 green unit tests behind it.
|
|
@@ -34,6 +34,8 @@ A rule about who may do what, and which way it fails when it is unsure.
|
|
|
34
34
|
- [Agent tool permission filtering reads the live function config, not the metadata](ai-agent-tool-filtering-reads-the-live-function-config.md) — The pikkuAuth brand survives only on live permission objects, so a metadata-driven check would silently admit every gated tool
|
|
35
35
|
- [An agent approval is claimed before the tool runs](an-agent-approval-is-claimed-before-the-tool-runs.md) — resolveApproval is a compare-and-swap returning whether this caller won, because the read that precedes it is not a claim and ten concurrent approvals would otherwise mean ten refunds
|
|
36
36
|
- [An empty owners constraint matches nothing](an-empty-owners-constraint-matches-nothing.md) — owners is an authorization boundary, so every storage backend must treat [] as no rows rather than no filter
|
|
37
|
+
- [An MCP refusal is decided before dispatch, not raised from the tool](an-mcp-refusal-is-decided-before-dispatch.md) — mcpTargetRequiresSession lets the transport answer 401 with an OAuth challenge, because the MCP response streams and the status is gone by the time the runner refuses
|
|
38
|
+
- [An OAuth2 credential is read by its account row, not by its provider name](an-oauth2-credential-is-read-by-its-account-row.md) — better-auth 1.7.5 selects an account by row id under a strict body schema, so BetterAuthCredentialService resolves the row through the internal adapter before asking for a token
|
|
37
39
|
- [An exposed function with no gate is reported at codegen, not at boot](an-exposed-ungated-function-is-a-codegen-warning.md) — The check runs in the inspector where function meta and every wireAddon declaration are both in hand, because neither source alone can tell a gated function from an ungated one
|
|
38
40
|
- [An upload is counted as it arrives, not buffered and then measured](an-upload-is-counted-as-it-arrives-not-buffered-then-measured.md) — Reading the whole body before checking its size hands an unauthenticated caller a way to spend the server's memory
|
|
39
41
|
- [The console addon's privileged functions gate themselves](console-addon-privileged-functions-gate-themselves.md) — Thread listing is owner-scoped unless the caller holds admin, and addon installation requires an admin session, rather than trusting the host to register a global permission
|
package/package.json
CHANGED
package/src/public-surface.json
CHANGED
|
@@ -216,6 +216,7 @@
|
|
|
216
216
|
"getMCPPromptsMeta",
|
|
217
217
|
"getMCPResourcesMeta",
|
|
218
218
|
"getMCPToolsMeta",
|
|
219
|
+
"mcpTargetRequiresSession",
|
|
219
220
|
"runMCPPrompt",
|
|
220
221
|
"runMCPResource",
|
|
221
222
|
"runMCPTool",
|
|
@@ -263,6 +264,18 @@
|
|
|
263
264
|
"pikkuAgentScorer",
|
|
264
265
|
"wireAgentScorerQueueWorkers"
|
|
265
266
|
],
|
|
267
|
+
"./analytics": [
|
|
268
|
+
"LoggerAnalyticsService",
|
|
269
|
+
"anonymousAnalyticsIdentity",
|
|
270
|
+
"composeAnalyticsIdentity",
|
|
271
|
+
"cookieAnalyticsIdentity",
|
|
272
|
+
"createInvocationAnalytics",
|
|
273
|
+
"defineAnalyticsEvents",
|
|
274
|
+
"fanOutAnalytics",
|
|
275
|
+
"flattenAnalyticsEvent",
|
|
276
|
+
"mintCookie",
|
|
277
|
+
"randomDigits"
|
|
278
|
+
],
|
|
266
279
|
"./gateway": [
|
|
267
280
|
"createListenerMessageHandler",
|
|
268
281
|
"resolveGatewayAdapter",
|
|
@@ -272,10 +285,12 @@
|
|
|
272
285
|
"CLIError",
|
|
273
286
|
"defineCLICommands",
|
|
274
287
|
"executeCLI",
|
|
288
|
+
"formatCLIError",
|
|
275
289
|
"generateCommandHelp",
|
|
276
290
|
"parseCLIArguments",
|
|
277
291
|
"pikkuCLIRender",
|
|
278
292
|
"runCLICommand",
|
|
293
|
+
"wantsStackTrace",
|
|
279
294
|
"wireCLI"
|
|
280
295
|
],
|
|
281
296
|
"./cli/command-parser": [
|
|
@@ -563,17 +578,5 @@
|
|
|
563
578
|
"pikkuDevReloader",
|
|
564
579
|
"reconcileAddonRegistry",
|
|
565
580
|
"reloadGeneratedMeta"
|
|
566
|
-
],
|
|
567
|
-
"./analytics": [
|
|
568
|
-
"LoggerAnalyticsService",
|
|
569
|
-
"anonymousAnalyticsIdentity",
|
|
570
|
-
"composeAnalyticsIdentity",
|
|
571
|
-
"cookieAnalyticsIdentity",
|
|
572
|
-
"createInvocationAnalytics",
|
|
573
|
-
"defineAnalyticsEvents",
|
|
574
|
-
"fanOutAnalytics",
|
|
575
|
-
"flattenAnalyticsEvent",
|
|
576
|
-
"mintCookie",
|
|
577
|
-
"randomDigits"
|
|
578
581
|
]
|
|
579
582
|
}
|