apps 1.0.0-beta.2 → 1.0.0-beta.4
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/dist/authoring/index.d.ts +254 -37
- package/dist/authoring/index.js +11 -1
- package/dist/{chunk-OGCPVWAZ.js → chunk-2DVKSPQH.js} +134 -13
- package/dist/{chunk-NFLNA2SC.js → chunk-4OZFMCTT.js} +7 -1
- package/dist/{chunk-LWKQDDLZ.js → chunk-535V5K64.js} +61 -4
- package/dist/chunk-DP6UJCMX.js +27 -0
- package/dist/chunk-QA72XJZU.js +41 -0
- package/dist/chunk-S5KFP4JQ.js +20 -0
- package/dist/{chunk-646MS7DD.js → chunk-XYT6GZ7M.js} +7 -7
- package/dist/http/index.d.ts +9 -0
- package/dist/http/index.js +6 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +23 -1
- package/dist/manifest/contract.d.ts +8 -0
- package/dist/manifest/index.js +3 -3
- package/dist/mcp/client.d.ts +9 -0
- package/dist/mcp/index.d.ts +11 -0
- package/dist/mcp/index.js +7 -0
- package/dist/openapi/index.d.ts +14 -0
- package/dist/openapi/index.js +7 -0
- package/dist/openapi/mapper.d.ts +7 -0
- package/dist/runner/index.js +29 -15
- package/dist/runner/protocol.d.ts +18 -5
- package/dist/runner/protocol.js +5 -3
- package/dist/runtime/index.d.ts +42 -5
- package/dist/runtime/index.js +3 -1
- package/dist/workerd/index.d.ts +1 -1
- package/dist/workerd/index.js +1 -1
- package/package.json +13 -6
- package/src/authoring/index.ts +395 -49
- package/src/http/index.ts +26 -0
- package/src/index.ts +3 -0
- package/src/manifest/contract.ts +9 -0
- package/src/mcp/client.ts +59 -0
- package/src/mcp/index.ts +42 -0
- package/src/openapi/index.ts +70 -0
- package/src/openapi/mapper.ts +25 -0
- package/src/runner/index.ts +35 -12
- package/src/runner/protocol.ts +32 -7
- package/src/runtime/index.ts +216 -25
- package/src/workerd/index.ts +2 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Stub-contract MCP client used by the fromMCP dynamic pair. */
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AuthComposeFn,
|
|
5
|
+
DiscoveredToolSpec,
|
|
6
|
+
OperationSelection,
|
|
7
|
+
} from "../authoring/index";
|
|
8
|
+
|
|
9
|
+
/** Normalize authored placement, or apply MCP's bearer default. */
|
|
10
|
+
export const composePlacement = (
|
|
11
|
+
auth: AuthComposeFn<never> | undefined,
|
|
12
|
+
ctx: unknown,
|
|
13
|
+
): { headers: Headers; searchParams: URLSearchParams } => {
|
|
14
|
+
const headers = new Headers();
|
|
15
|
+
const searchParams = new URLSearchParams();
|
|
16
|
+
const roles = ctx as Record<string, { token?: string }> | undefined;
|
|
17
|
+
|
|
18
|
+
if (auth === undefined) {
|
|
19
|
+
const role = roles === undefined ? undefined : Object.values(roles)[0];
|
|
20
|
+
if (role?.token !== undefined) headers.set("authorization", `Bearer ${role.token}`);
|
|
21
|
+
return { headers, searchParams };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const placement = (auth as (roles: unknown) => unknown)(roles ?? {});
|
|
25
|
+
if (placement !== undefined && placement !== null && typeof placement === "object") {
|
|
26
|
+
const normalized = placement as {
|
|
27
|
+
readonly headers?: HeadersInit;
|
|
28
|
+
readonly searchParams?: URLSearchParams | Readonly<Record<string, string>>;
|
|
29
|
+
};
|
|
30
|
+
if (normalized.headers !== undefined)
|
|
31
|
+
new Headers(normalized.headers).forEach((value, key) => headers.set(key, value));
|
|
32
|
+
if (normalized.searchParams !== undefined)
|
|
33
|
+
new URLSearchParams(
|
|
34
|
+
normalized.searchParams instanceof URLSearchParams
|
|
35
|
+
? normalized.searchParams
|
|
36
|
+
: { ...normalized.searchParams },
|
|
37
|
+
).forEach((value, key) => searchParams.set(key, value));
|
|
38
|
+
}
|
|
39
|
+
return { headers, searchParams };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const mcpDiscover = async (
|
|
43
|
+
_endpoint: string,
|
|
44
|
+
_ctx: unknown,
|
|
45
|
+
_auth: AuthComposeFn<never> | undefined,
|
|
46
|
+
_operations: OperationSelection | undefined,
|
|
47
|
+
): Promise<readonly DiscoveredToolSpec[]> => {
|
|
48
|
+
throw new Error("mcp client not built yet");
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const mcpCall = async (
|
|
52
|
+
_endpoint: string,
|
|
53
|
+
_tool: string,
|
|
54
|
+
_input: unknown,
|
|
55
|
+
_ctx: unknown,
|
|
56
|
+
_auth: AuthComposeFn<never> | undefined,
|
|
57
|
+
): Promise<unknown> => {
|
|
58
|
+
throw new Error("mcp client not built yet");
|
|
59
|
+
};
|
package/src/mcp/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** fromMCP sugar over the authored dynamic-tools primitive. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
defineDynamicTools,
|
|
5
|
+
type AuthComposeFn,
|
|
6
|
+
type DynamicToolSet,
|
|
7
|
+
type OperationSelection,
|
|
8
|
+
type Requirement,
|
|
9
|
+
} from "../authoring/index";
|
|
10
|
+
import { mcpCall, mcpDiscover } from "./client";
|
|
11
|
+
|
|
12
|
+
export type FromMCPOptions<Reqs extends Readonly<Record<string, Requirement>>> = {
|
|
13
|
+
readonly requirements: Reqs;
|
|
14
|
+
readonly auth?: AuthComposeFn<NoInfer<Reqs>>;
|
|
15
|
+
readonly operations?: OperationSelection;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function fromMCP<const Reqs extends Readonly<Record<string, Requirement>>>(
|
|
19
|
+
endpoint: string,
|
|
20
|
+
options: FromMCPOptions<Reqs>,
|
|
21
|
+
): DynamicToolSet;
|
|
22
|
+
export function fromMCP(
|
|
23
|
+
endpoint: string,
|
|
24
|
+
options?: { readonly operations?: OperationSelection },
|
|
25
|
+
): DynamicToolSet;
|
|
26
|
+
export function fromMCP(
|
|
27
|
+
endpoint: string,
|
|
28
|
+
options?: {
|
|
29
|
+
readonly requirements?: Readonly<Record<string, Requirement>>;
|
|
30
|
+
readonly auth?: AuthComposeFn<never>;
|
|
31
|
+
readonly operations?: OperationSelection;
|
|
32
|
+
},
|
|
33
|
+
): DynamicToolSet {
|
|
34
|
+
const auth = options?.auth;
|
|
35
|
+
const operations = options?.operations;
|
|
36
|
+
return defineDynamicTools({
|
|
37
|
+
description: `Tools served by the MCP server at ${endpoint}.`,
|
|
38
|
+
...(options?.requirements === undefined ? {} : { requirements: options.requirements }),
|
|
39
|
+
discover: ({ ctx }) => mcpDiscover(endpoint, ctx, auth, operations),
|
|
40
|
+
call: ({ tool, input, ctx }) => mcpCall(endpoint, tool.name, input, ctx, auth),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** fromOpenAPI sugar over the authored dynamic-tools primitive. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
defineDynamicTools,
|
|
5
|
+
recordAuthoringProblem,
|
|
6
|
+
requirementSpecOf,
|
|
7
|
+
type AuthComposeFn,
|
|
8
|
+
type DynamicToolSet,
|
|
9
|
+
type OperationSelection,
|
|
10
|
+
type Requirement,
|
|
11
|
+
type RequiresAuthCompose,
|
|
12
|
+
} from "../authoring/index";
|
|
13
|
+
import { openapiCall, openapiDiscover } from "./mapper";
|
|
14
|
+
|
|
15
|
+
export type FromOpenAPIOptions<Reqs extends Readonly<Record<string, Requirement>>> = {
|
|
16
|
+
readonly requirements: Reqs;
|
|
17
|
+
readonly auth?: AuthComposeFn<NoInfer<Reqs>>;
|
|
18
|
+
readonly operations?: OperationSelection;
|
|
19
|
+
} & (RequiresAuthCompose<Reqs> extends true
|
|
20
|
+
? { readonly auth: AuthComposeFn<NoInfer<Reqs>> }
|
|
21
|
+
: unknown);
|
|
22
|
+
|
|
23
|
+
const enforceSecretsPlacement = (
|
|
24
|
+
requirements: Readonly<Record<string, Requirement>> | undefined,
|
|
25
|
+
auth: AuthComposeFn<never> | undefined,
|
|
26
|
+
): void => {
|
|
27
|
+
if (auth !== undefined) return;
|
|
28
|
+
for (const value of Object.values(requirements ?? {})) {
|
|
29
|
+
const spec = requirementSpecOf(value);
|
|
30
|
+
const methods = spec?.grant === undefined
|
|
31
|
+
? Object.values(spec?.service.auth ?? {})
|
|
32
|
+
: [spec.service.auth?.[spec.grant]].filter((method) => method !== undefined);
|
|
33
|
+
if (methods.some((method) => method.kind === "secrets"))
|
|
34
|
+
recordAuthoringProblem(
|
|
35
|
+
"fromOpenAPI: the service offers a secrets() auth method, so 'auth' must state how the credential rides",
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type OpenAPIDocumentInput = string | Readonly<Record<string, unknown>>;
|
|
41
|
+
|
|
42
|
+
export function fromOpenAPI<const Reqs extends Readonly<Record<string, Requirement>>>(
|
|
43
|
+
spec: OpenAPIDocumentInput,
|
|
44
|
+
options: FromOpenAPIOptions<Reqs>,
|
|
45
|
+
): DynamicToolSet;
|
|
46
|
+
export function fromOpenAPI(
|
|
47
|
+
spec: OpenAPIDocumentInput,
|
|
48
|
+
options?: { readonly operations?: OperationSelection },
|
|
49
|
+
): DynamicToolSet;
|
|
50
|
+
export function fromOpenAPI(
|
|
51
|
+
spec: OpenAPIDocumentInput,
|
|
52
|
+
options?: {
|
|
53
|
+
readonly requirements?: Readonly<Record<string, Requirement>>;
|
|
54
|
+
readonly auth?: AuthComposeFn<never>;
|
|
55
|
+
readonly operations?: OperationSelection;
|
|
56
|
+
},
|
|
57
|
+
): DynamicToolSet {
|
|
58
|
+
enforceSecretsPlacement(options?.requirements, options?.auth);
|
|
59
|
+
const auth = options?.auth;
|
|
60
|
+
const operations = options?.operations;
|
|
61
|
+
return defineDynamicTools({
|
|
62
|
+
description:
|
|
63
|
+
typeof spec === "string" && spec.startsWith("https://")
|
|
64
|
+
? `Operations from the OpenAPI document at ${spec}.`
|
|
65
|
+
: "Operations from an OpenAPI document shipped with the app.",
|
|
66
|
+
...(options?.requirements === undefined ? {} : { requirements: options.requirements }),
|
|
67
|
+
discover: ({ ctx }) => openapiDiscover(spec, ctx, auth, operations),
|
|
68
|
+
call: ({ tool, input, ctx }) => openapiCall(tool, input, ctx, auth),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Stub-contract OpenAPI discovery and call mapper. */
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AuthComposeFn,
|
|
5
|
+
DiscoveredToolSpec,
|
|
6
|
+
OperationSelection,
|
|
7
|
+
} from "../authoring/index";
|
|
8
|
+
|
|
9
|
+
export const openapiDiscover = async (
|
|
10
|
+
_spec: string | Readonly<Record<string, unknown>>,
|
|
11
|
+
_ctx: unknown,
|
|
12
|
+
_auth: AuthComposeFn<never> | undefined,
|
|
13
|
+
_operations: OperationSelection | undefined,
|
|
14
|
+
): Promise<readonly DiscoveredToolSpec[]> => {
|
|
15
|
+
throw new Error("openapi mapper not built yet");
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const openapiCall = async (
|
|
19
|
+
_tool: { readonly name: string; readonly detail: unknown },
|
|
20
|
+
_input: unknown,
|
|
21
|
+
_ctx: unknown,
|
|
22
|
+
_auth: AuthComposeFn<never> | undefined,
|
|
23
|
+
): Promise<unknown> => {
|
|
24
|
+
throw new Error("openapi mapper not built yet");
|
|
25
|
+
};
|
package/src/runner/index.ts
CHANGED
|
@@ -4,8 +4,13 @@ import {
|
|
|
4
4
|
type LoadedApp,
|
|
5
5
|
type PublishError,
|
|
6
6
|
} from "../manifest/index";
|
|
7
|
-
import {
|
|
8
|
-
|
|
7
|
+
import {
|
|
8
|
+
callTool,
|
|
9
|
+
discoverTool,
|
|
10
|
+
type CallOutcome,
|
|
11
|
+
type DiscoverOutcome,
|
|
12
|
+
} from "../runtime/index";
|
|
13
|
+
import { CallError, DiscoverError, type AppRunner } from "./protocol";
|
|
9
14
|
|
|
10
15
|
export * from "./protocol";
|
|
11
16
|
|
|
@@ -30,28 +35,46 @@ export const localRunner = (appDir: string): AppRunner => {
|
|
|
30
35
|
loaded().pipe(
|
|
31
36
|
Effect.map(({ registry }) => registry),
|
|
32
37
|
Effect.mapError(
|
|
33
|
-
(error) => new CallError({
|
|
38
|
+
(error) => new CallError({
|
|
39
|
+
target,
|
|
40
|
+
kind: "handler",
|
|
41
|
+
reason: `app failed to load (${error.message})`,
|
|
42
|
+
}),
|
|
34
43
|
),
|
|
35
44
|
);
|
|
36
45
|
|
|
37
46
|
const settle = (target: string, outcome: CallOutcome): Effect.Effect<unknown, CallError> =>
|
|
38
47
|
outcome.ok
|
|
39
48
|
? Effect.succeed(outcome.value)
|
|
40
|
-
: Effect.fail(new CallError({
|
|
49
|
+
: Effect.fail(new CallError({
|
|
50
|
+
target,
|
|
51
|
+
kind: outcome.kind,
|
|
52
|
+
reason: outcome.reason,
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
const settleDiscovery = (
|
|
56
|
+
segment: string,
|
|
57
|
+
outcome: DiscoverOutcome,
|
|
58
|
+
) => outcome.ok
|
|
59
|
+
? Effect.succeed(outcome.value)
|
|
60
|
+
: Effect.fail(new DiscoverError({ segment, reason: outcome.reason }));
|
|
41
61
|
|
|
42
62
|
return {
|
|
43
63
|
manifest: () => Effect.map(loaded(), ({ result }) => result),
|
|
44
|
-
call: (
|
|
45
|
-
registryFor(
|
|
46
|
-
Effect.flatMap((registry) =>
|
|
47
|
-
|
|
64
|
+
call: (address, input, ctx, detail) =>
|
|
65
|
+
registryFor(address).pipe(
|
|
66
|
+
Effect.flatMap((registry) =>
|
|
67
|
+
Effect.promise(() => callTool(registry, address, input, ctx, detail)),
|
|
68
|
+
),
|
|
69
|
+
Effect.flatMap((outcome) => settle(address, outcome)),
|
|
48
70
|
),
|
|
49
|
-
discover: (
|
|
50
|
-
registryFor(
|
|
71
|
+
discover: (segment, ctx) =>
|
|
72
|
+
registryFor(segment).pipe(
|
|
73
|
+
Effect.mapError((error) => new DiscoverError({ segment, reason: error.reason })),
|
|
51
74
|
Effect.flatMap((registry) =>
|
|
52
|
-
Effect.promise(() => discoverTool(registry,
|
|
75
|
+
Effect.promise(() => discoverTool(registry, segment, ctx)),
|
|
53
76
|
),
|
|
54
|
-
Effect.flatMap((outcome) =>
|
|
77
|
+
Effect.flatMap((outcome) => settleDiscovery(segment, outcome)),
|
|
55
78
|
),
|
|
56
79
|
};
|
|
57
80
|
};
|
package/src/runner/protocol.ts
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
import { Data, type Effect, FileSystem } from "effect";
|
|
2
2
|
import type { AppManifestResult, PublishError } from "../manifest/contract";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
CallFailureKind,
|
|
5
|
+
DiscoveredTool,
|
|
6
|
+
ToolCtx,
|
|
7
|
+
ToolCtxEntry,
|
|
8
|
+
} from "../runtime/index";
|
|
4
9
|
|
|
5
|
-
export type {
|
|
10
|
+
export type {
|
|
11
|
+
DiscoveredTool,
|
|
12
|
+
ToolCtx,
|
|
13
|
+
ToolCtxEntry,
|
|
14
|
+
ToolDescriptor,
|
|
15
|
+
} from "../runtime/index";
|
|
6
16
|
|
|
7
|
-
/**
|
|
17
|
+
/** Which invocation phase failed. */
|
|
18
|
+
export type CallErrorKind = CallFailureKind;
|
|
19
|
+
|
|
20
|
+
/** A runner operation failure associated with its target and phase. */
|
|
8
21
|
export class CallError extends Data.TaggedError("CallError")<{
|
|
9
22
|
readonly target: string;
|
|
23
|
+
readonly kind: CallErrorKind;
|
|
10
24
|
readonly reason: string;
|
|
11
25
|
}> {
|
|
12
26
|
override get message(): string {
|
|
@@ -14,16 +28,27 @@ export class CallError extends Data.TaggedError("CallError")<{
|
|
|
14
28
|
}
|
|
15
29
|
}
|
|
16
30
|
|
|
31
|
+
/** A discovery failure associated with one dynamic segment. */
|
|
32
|
+
export class DiscoverError extends Data.TaggedError("DiscoverError")<{
|
|
33
|
+
readonly segment: string;
|
|
34
|
+
readonly reason: string;
|
|
35
|
+
}> {
|
|
36
|
+
override get message(): string {
|
|
37
|
+
return `${this.segment}: ${this.reason}`;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
17
41
|
/** The platform protocol implemented by an app execution environment. */
|
|
18
42
|
export interface AppRunner {
|
|
19
43
|
manifest(): Effect.Effect<AppManifestResult, PublishError, FileSystem.FileSystem>;
|
|
20
44
|
call(
|
|
21
|
-
|
|
45
|
+
address: string,
|
|
22
46
|
input: unknown,
|
|
23
47
|
ctx: ToolCtx,
|
|
48
|
+
detail?: unknown,
|
|
24
49
|
): Effect.Effect<unknown, CallError, FileSystem.FileSystem>;
|
|
25
50
|
discover(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
): Effect.Effect<
|
|
51
|
+
segment: string,
|
|
52
|
+
ctx: ToolCtx,
|
|
53
|
+
): Effect.Effect<readonly DiscoveredTool[], DiscoverError, FileSystem.FileSystem>;
|
|
29
54
|
}
|
package/src/runtime/index.ts
CHANGED
|
@@ -12,12 +12,17 @@
|
|
|
12
12
|
* and workerd all provide.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import type {
|
|
15
|
+
import type {
|
|
16
|
+
DiscoveredToolSpec,
|
|
17
|
+
DynamicToolsSpec,
|
|
18
|
+
Registry,
|
|
19
|
+
} from "../authoring/index";
|
|
16
20
|
import type {
|
|
17
21
|
AppManifestResult,
|
|
18
22
|
CollectedClaim,
|
|
19
23
|
CollectedRequirement,
|
|
20
24
|
JsonSchemaObject,
|
|
25
|
+
ManifestDynamic,
|
|
21
26
|
ManifestTool,
|
|
22
27
|
} from "../manifest/contract";
|
|
23
28
|
|
|
@@ -26,7 +31,10 @@ import type {
|
|
|
26
31
|
/** The capability exposed to one tool requirement role. */
|
|
27
32
|
export interface ToolCtxEntry {
|
|
28
33
|
readonly grant: string;
|
|
34
|
+
readonly kind: "bearer" | "basic" | "secrets" | "oauth";
|
|
29
35
|
readonly token?: string;
|
|
36
|
+
readonly username?: string;
|
|
37
|
+
readonly password?: string;
|
|
30
38
|
readonly secrets?: Readonly<Record<string, string>>;
|
|
31
39
|
fetch(path: string, init?: RequestInit): Promise<Response>;
|
|
32
40
|
}
|
|
@@ -41,12 +49,46 @@ export type ManifestOutcome =
|
|
|
41
49
|
| { readonly ok: true; readonly result: AppManifestResult }
|
|
42
50
|
| { readonly ok: false; readonly problems: readonly string[] };
|
|
43
51
|
|
|
52
|
+
/** Which invocation phase failed. */
|
|
53
|
+
export type CallFailureKind = "input" | "handler";
|
|
54
|
+
|
|
44
55
|
/** One tool invocation's result envelope. */
|
|
45
56
|
export type CallOutcome =
|
|
46
57
|
| { readonly ok: true; readonly value: unknown }
|
|
58
|
+
| {
|
|
59
|
+
readonly ok: false;
|
|
60
|
+
readonly kind: CallFailureKind;
|
|
61
|
+
readonly reason: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Agent-facing descriptor compiled from one discovery result. */
|
|
65
|
+
export interface ToolDescriptor {
|
|
66
|
+
readonly description: string;
|
|
67
|
+
readonly inputSchema: JsonSchemaObject;
|
|
68
|
+
readonly annotations: {
|
|
69
|
+
readonly readOnly?: boolean;
|
|
70
|
+
readonly destructive?: boolean;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One tool emitted by a dynamic segment discovery. */
|
|
75
|
+
export interface DiscoveredTool {
|
|
76
|
+
readonly segment: string;
|
|
77
|
+
readonly name: string;
|
|
78
|
+
readonly descriptor: ToolDescriptor;
|
|
79
|
+
readonly detail: unknown;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One dynamic discovery result envelope. */
|
|
83
|
+
export type DiscoverOutcome =
|
|
84
|
+
| { readonly ok: true; readonly value: readonly DiscoveredTool[] }
|
|
47
85
|
| { readonly ok: false; readonly reason: string };
|
|
48
86
|
|
|
49
|
-
const failure = (reason: string): CallOutcome => ({
|
|
87
|
+
const failure = (kind: CallFailureKind, reason: string): CallOutcome => ({
|
|
88
|
+
ok: false,
|
|
89
|
+
kind,
|
|
90
|
+
reason,
|
|
91
|
+
});
|
|
50
92
|
|
|
51
93
|
const errorMessage = (error: unknown): string =>
|
|
52
94
|
error instanceof Error ? error.message : String(error);
|
|
@@ -106,7 +148,7 @@ type InputSchemaCompilation =
|
|
|
106
148
|
| { readonly value: JsonSchemaObject }
|
|
107
149
|
| { readonly problem: string };
|
|
108
150
|
|
|
109
|
-
const compileInputSchema = (input: object, where: string): InputSchemaCompilation => {
|
|
151
|
+
export const compileInputSchema = (input: object, where: string): InputSchemaCompilation => {
|
|
110
152
|
const standard = standardJsonSchemaOf(input);
|
|
111
153
|
if ("~standard" in input && typeof standard?.input !== "function")
|
|
112
154
|
return {
|
|
@@ -152,6 +194,11 @@ const requirementSpecOf = (value: unknown): RequirementSpecShape | undefined =>
|
|
|
152
194
|
? ((value as Record<PropertyKey, unknown>)[requirementKey] as RequirementSpecShape)
|
|
153
195
|
: undefined;
|
|
154
196
|
|
|
197
|
+
const segmentOf = (file: string): string => {
|
|
198
|
+
const name = file.slice(file.lastIndexOf("/") + 1);
|
|
199
|
+
return name.endsWith(".ts") ? name.slice(0, -3) : name;
|
|
200
|
+
};
|
|
201
|
+
|
|
155
202
|
const canonicalJson = (value: unknown): string => {
|
|
156
203
|
const sortKeys = (node: unknown): unknown => {
|
|
157
204
|
if (Array.isArray(node)) return node.map(sortKeys);
|
|
@@ -170,6 +217,7 @@ interface CollectedManifest {
|
|
|
170
217
|
readonly app: { readonly key: string; readonly label: string; readonly description: string };
|
|
171
218
|
readonly services: readonly CollectedClaim[];
|
|
172
219
|
readonly tools: readonly ManifestTool[];
|
|
220
|
+
readonly dynamic: readonly ManifestDynamic[];
|
|
173
221
|
}
|
|
174
222
|
|
|
175
223
|
type RegistryCollection =
|
|
@@ -229,6 +277,8 @@ const collectRegistry = (registry: Registry): RegistryCollection => {
|
|
|
229
277
|
for (const [file, summary] of registry.toolFiles) {
|
|
230
278
|
if (summary.definitions > 1 && summary.hasUnnamedTool)
|
|
231
279
|
problems.push(`${file}: multi-tool files must name every tool`);
|
|
280
|
+
if (registry.dynamic.has(file))
|
|
281
|
+
problems.push(`${file}: a file claims a dynamic tool set or defines tools, never both`);
|
|
232
282
|
}
|
|
233
283
|
|
|
234
284
|
const tools: ManifestTool[] = [];
|
|
@@ -261,13 +311,47 @@ const collectRegistry = (registry: Registry): RegistryCollection => {
|
|
|
261
311
|
}
|
|
262
312
|
}
|
|
263
313
|
|
|
314
|
+
const dynamic: ManifestDynamic[] = [];
|
|
315
|
+
const seenSegments = new Map<string, string>();
|
|
316
|
+
for (const [file, registered] of registry.dynamic) {
|
|
317
|
+
const segment = segmentOf(file);
|
|
318
|
+
const where = `${file}: dynamic tool set '${segment}'`;
|
|
319
|
+
const existing = seenSegments.get(segment);
|
|
320
|
+
if (existing !== undefined) {
|
|
321
|
+
problems.push(`${where}: segment already claimed by ${existing}`);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
seenSegments.set(segment, file);
|
|
325
|
+
|
|
326
|
+
const requires = Object.entries(registered.spec.requirements ?? {})
|
|
327
|
+
.flatMap(([role, requirement]) => {
|
|
328
|
+
const collected = collectRequirement(role, requirement, where);
|
|
329
|
+
return collected === undefined ? [] : [collected];
|
|
330
|
+
})
|
|
331
|
+
.sort((left, right) => left.role.localeCompare(right.role));
|
|
332
|
+
dynamic.push({
|
|
333
|
+
segment,
|
|
334
|
+
description: registered.spec.description,
|
|
335
|
+
requires,
|
|
336
|
+
file,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
264
340
|
if (problems.length > 0 || app === undefined) return { problems };
|
|
265
341
|
const services = [...claims.values()].sort((left, right) =>
|
|
266
342
|
left.origin.localeCompare(right.origin),
|
|
267
343
|
);
|
|
268
344
|
const sortedTools = [...tools].sort((left, right) => left.address.localeCompare(right.address));
|
|
345
|
+
const sortedDynamic = [...dynamic].sort((left, right) =>
|
|
346
|
+
left.segment.localeCompare(right.segment),
|
|
347
|
+
);
|
|
269
348
|
const appResult = { key: app.key, label: app.label, description: app.description };
|
|
270
|
-
const manifest = {
|
|
349
|
+
const manifest = {
|
|
350
|
+
app: appResult,
|
|
351
|
+
services,
|
|
352
|
+
tools: sortedTools,
|
|
353
|
+
dynamic: sortedDynamic,
|
|
354
|
+
} satisfies CollectedManifest;
|
|
271
355
|
return { problems: [], manifest, canonical: canonicalJson(manifest) };
|
|
272
356
|
};
|
|
273
357
|
|
|
@@ -314,8 +398,8 @@ type ParsedInput =
|
|
|
314
398
|
| { readonly ok: true; readonly value: unknown }
|
|
315
399
|
| { readonly ok: false; readonly reason: string };
|
|
316
400
|
|
|
317
|
-
const parseInput = async (
|
|
318
|
-
const validate = standardValidateOf(
|
|
401
|
+
const parseInput = async (schema: object, input: unknown): Promise<ParsedInput> => {
|
|
402
|
+
const validate = standardValidateOf(schema);
|
|
319
403
|
if (validate === undefined)
|
|
320
404
|
return { ok: false, reason: "input schema does not implement Standard Schema validate" };
|
|
321
405
|
try {
|
|
@@ -331,33 +415,140 @@ const parseInput = async (spec: ToolSpec, input: unknown): Promise<ParsedInput>
|
|
|
331
415
|
}
|
|
332
416
|
};
|
|
333
417
|
|
|
334
|
-
|
|
418
|
+
const splitDynamicAddress = (
|
|
419
|
+
address: string,
|
|
420
|
+
): { readonly segment: string; readonly name: string } | undefined => {
|
|
421
|
+
const slash = address.indexOf("/");
|
|
422
|
+
if (slash <= 0 || slash === address.length - 1) return undefined;
|
|
423
|
+
return { segment: address.slice(0, slash), name: address.slice(slash + 1) };
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const dynamicFor = (
|
|
427
|
+
registry: Registry,
|
|
428
|
+
segment: string,
|
|
429
|
+
): DynamicToolsSpec | undefined => {
|
|
430
|
+
for (const [file, registered] of registry.dynamic)
|
|
431
|
+
if (segmentOf(file) === segment) return registered.spec;
|
|
432
|
+
return undefined;
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
/** Live schemas from the latest successful discovery, retained per loaded registry. */
|
|
436
|
+
const discoveredSpecs = new WeakMap<
|
|
437
|
+
Registry,
|
|
438
|
+
Map<string, ReadonlyMap<string, DiscoveredToolSpec>>
|
|
439
|
+
>();
|
|
440
|
+
|
|
441
|
+
/** Validate input and run one static or dynamic tool handler. */
|
|
335
442
|
export const callTool = async (
|
|
336
443
|
registry: Registry,
|
|
337
|
-
|
|
444
|
+
address: string,
|
|
338
445
|
input: unknown,
|
|
339
446
|
ctx: ToolCtx,
|
|
447
|
+
detail?: unknown,
|
|
340
448
|
): Promise<CallOutcome> => {
|
|
341
|
-
const
|
|
342
|
-
if (
|
|
343
|
-
|
|
344
|
-
|
|
449
|
+
const dynamic = splitDynamicAddress(address);
|
|
450
|
+
if (dynamic === undefined) {
|
|
451
|
+
if (detail !== undefined)
|
|
452
|
+
return failure("handler", "a static tool takes no discovery detail");
|
|
453
|
+
const registered = registry.tools.get(address);
|
|
454
|
+
if (registered === undefined) return failure("handler", "unknown tool");
|
|
455
|
+
const parsed = await parseInput(registered.spec.input, input);
|
|
456
|
+
if (!parsed.ok) return failure("input", parsed.reason);
|
|
457
|
+
try {
|
|
458
|
+
// SAFETY: ToolSpec intentionally erases authored handler inputs to keep
|
|
459
|
+
// authoring generic-free; validation and ToolCtx establish this call boundary.
|
|
460
|
+
const handler = registered.spec.handler as (args: {
|
|
461
|
+
readonly input: unknown;
|
|
462
|
+
readonly ctx: ToolCtx;
|
|
463
|
+
}) => Promise<unknown>;
|
|
464
|
+
return { ok: true, value: await handler({ input: parsed.value, ctx }) };
|
|
465
|
+
} catch (error) {
|
|
466
|
+
return failure("handler", `handler failed (${errorMessage(error)})`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (detail === undefined)
|
|
471
|
+
return failure("handler", "a dynamic tool requires discovery detail");
|
|
472
|
+
const pair = dynamicFor(registry, dynamic.segment);
|
|
473
|
+
if (pair === undefined)
|
|
474
|
+
return failure("handler", `unknown dynamic tool set '${dynamic.segment}'`);
|
|
475
|
+
const discovered = discoveredSpecs.get(registry)?.get(dynamic.segment)?.get(dynamic.name);
|
|
476
|
+
if (discovered === undefined)
|
|
477
|
+
return failure(
|
|
478
|
+
"input",
|
|
479
|
+
`dynamic tool '${dynamic.name}' has no compiled discovery descriptor`,
|
|
480
|
+
);
|
|
481
|
+
const parsed = await parseInput(discovered.input, input);
|
|
482
|
+
if (!parsed.ok) return failure("input", parsed.reason);
|
|
345
483
|
try {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
484
|
+
const call = pair.call as (args: {
|
|
485
|
+
readonly tool: { readonly name: string; readonly detail: unknown };
|
|
486
|
+
readonly input: unknown;
|
|
487
|
+
readonly ctx: ToolCtx;
|
|
488
|
+
}) => Promise<unknown>;
|
|
489
|
+
return {
|
|
490
|
+
ok: true,
|
|
491
|
+
value: await call({
|
|
492
|
+
tool: { name: dynamic.name, detail },
|
|
493
|
+
input: parsed.value,
|
|
494
|
+
ctx,
|
|
495
|
+
}),
|
|
496
|
+
};
|
|
353
497
|
} catch (error) {
|
|
354
|
-
return failure(
|
|
498
|
+
return failure("handler", `dynamic call failed (${errorMessage(error)})`);
|
|
355
499
|
}
|
|
356
500
|
};
|
|
357
501
|
|
|
358
|
-
/** Discover one dynamic
|
|
502
|
+
/** Discover and compile one dynamic segment. */
|
|
359
503
|
export const discoverTool = async (
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
): Promise<
|
|
504
|
+
registry: Registry,
|
|
505
|
+
segment: string,
|
|
506
|
+
ctx: ToolCtx,
|
|
507
|
+
): Promise<DiscoverOutcome> => {
|
|
508
|
+
const pair = dynamicFor(registry, segment);
|
|
509
|
+
if (pair === undefined)
|
|
510
|
+
return { ok: false, reason: `unknown dynamic tool set '${segment}'` };
|
|
511
|
+
discoveredSpecs.get(registry)?.delete(segment);
|
|
512
|
+
|
|
513
|
+
let emitted: readonly DiscoveredToolSpec[];
|
|
514
|
+
try {
|
|
515
|
+
const discover = pair.discover as (args: {
|
|
516
|
+
readonly ctx: ToolCtx;
|
|
517
|
+
}) => Promise<readonly DiscoveredToolSpec[]>;
|
|
518
|
+
emitted = await discover({ ctx });
|
|
519
|
+
} catch (error) {
|
|
520
|
+
return { ok: false, reason: `discover failed (${errorMessage(error)})` };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const tools: DiscoveredTool[] = [];
|
|
524
|
+
const live = new Map<string, DiscoveredToolSpec>();
|
|
525
|
+
for (const tool of emitted) {
|
|
526
|
+
if (live.has(tool.name))
|
|
527
|
+
return { ok: false, reason: `discover emitted duplicate tool '${tool.name}'` };
|
|
528
|
+
const compiled = compileInputSchema(tool.input, `${segment}/${tool.name}`);
|
|
529
|
+
if ("problem" in compiled) return { ok: false, reason: compiled.problem };
|
|
530
|
+
live.set(tool.name, tool);
|
|
531
|
+
tools.push({
|
|
532
|
+
segment,
|
|
533
|
+
name: tool.name,
|
|
534
|
+
descriptor: {
|
|
535
|
+
description: tool.description,
|
|
536
|
+
inputSchema: compiled.value,
|
|
537
|
+
annotations: {
|
|
538
|
+
...(tool.annotations?.readOnly === undefined
|
|
539
|
+
? {}
|
|
540
|
+
: { readOnly: tool.annotations.readOnly }),
|
|
541
|
+
...(tool.annotations?.destructive === undefined
|
|
542
|
+
? {}
|
|
543
|
+
: { destructive: tool.annotations.destructive }),
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
detail: tool.detail ?? null,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const registryDiscoveries = discoveredSpecs.get(registry) ?? new Map();
|
|
551
|
+
registryDiscoveries.set(segment, live);
|
|
552
|
+
discoveredSpecs.set(registry, registryDiscoveries);
|
|
553
|
+
return { ok: true, value: tools.sort((left, right) => left.name.localeCompare(right.name)) };
|
|
554
|
+
};
|