holon-sh 0.0.1-dev.5 → 0.0.1-dev.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -26
- package/context.d.ts +19 -0
- package/context.mjs +6 -0
- package/package.json +10 -20
- package/protocol.d.ts +15 -11
- package/runtime.d.ts +3 -9
- package/runtime.mjs +71 -49
- package/validate.mjs +9 -7
- package/generated/holon-generated.json +0 -4
- package/generated/index.d.ts +0 -12
- package/generated/index.js +0 -5
- package/view.d.ts +0 -47
- package/view.js +0 -30
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ This repository contains the Node.js runtime for [`holon`](https://github.com/ho
|
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
12
|
npm install holon-sh@dev
|
|
13
|
-
npm install -D holon-types
|
|
13
|
+
npm install -D holon-types@next
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
`holon-types` is the `holon` CLI. `holon types check` brings its own TypeScript compiler and uses the project's `typescript` when one is installed.
|
|
@@ -25,11 +25,13 @@ The Holon TypeScript SDK allows generation of type-safe MCP clients. See [Exampl
|
|
|
25
25
|
npx holon types check
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
`holon types generate` and `holon types check` write the clients to `generated/holon-sh/` beside your `package.json`, and map `#holon-sh` to them in its `imports`. A package that publishes code calling them lists `generated/holon-sh` in its `files`, so the projects that install it need not generate.
|
|
29
|
+
|
|
28
30
|
<details open>
|
|
29
31
|
<summary><h3>Call a tool</h3></summary>
|
|
30
32
|
|
|
31
33
|
```ts
|
|
32
|
-
import { apps } from "holon-sh";
|
|
34
|
+
import { apps } from "#holon-sh";
|
|
33
35
|
|
|
34
36
|
export async function greet(name: string): Promise<string> {
|
|
35
37
|
const greeting = await apps.bar.tools.greet({ name }, { timeout: 5000 });
|
|
@@ -37,21 +39,21 @@ export async function greet(name: string): Promise<string> {
|
|
|
37
39
|
}
|
|
38
40
|
```
|
|
39
41
|
|
|
40
|
-
Arguments are validated before sending; the result is validated against the output schema and returned.
|
|
41
|
-
|
|
42
42
|
</details>
|
|
43
43
|
|
|
44
44
|
<details open>
|
|
45
45
|
<summary><h3>Handle errors</h3></summary>
|
|
46
46
|
|
|
47
47
|
```ts
|
|
48
|
-
import { ToolCallError, apps } from "holon-sh";
|
|
48
|
+
import { ToolCallError, apps } from "#holon-sh";
|
|
49
49
|
|
|
50
50
|
try {
|
|
51
51
|
await apps.bar.tools.greet({ name: "world" });
|
|
52
52
|
} catch (error) {
|
|
53
53
|
if (!(error instanceof ToolCallError)) throw error;
|
|
54
|
-
error.
|
|
54
|
+
error.message; // includes the server's text
|
|
55
|
+
error.data; // the server's structured content, or its text parsed as JSON
|
|
56
|
+
error.result; // the original MCP result
|
|
55
57
|
}
|
|
56
58
|
```
|
|
57
59
|
|
|
@@ -65,7 +67,7 @@ try {
|
|
|
65
67
|
```ts
|
|
66
68
|
const result = await apps.bar.callTool({ name: "greet", arguments: { name: "world" } });
|
|
67
69
|
if (result.resultType === "input_required") {
|
|
68
|
-
// call again with inputResponses and requestState
|
|
70
|
+
// call again with inputResponses and requestState
|
|
69
71
|
} else if (result.isError) {
|
|
70
72
|
// inspect result.content
|
|
71
73
|
} else {
|
|
@@ -78,27 +80,22 @@ if (result.resultType === "input_required") {
|
|
|
78
80
|
<details open>
|
|
79
81
|
<summary><h3>Answer a server's question</h3></summary>
|
|
80
82
|
|
|
81
|
-
A tool can stop and ask for input through an [MCP elicitation](https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation).
|
|
83
|
+
A tool can stop and ask for input through an [MCP elicitation](https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation). Pass `elicit` to answer in place, and the call returns its usual typed output:
|
|
82
84
|
|
|
83
85
|
```ts
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
endpoint: "https://bar.example.com/mcp",
|
|
87
|
-
elicit: async ({ params }) => ({ action: "accept", content: await ask(params.message) }),
|
|
88
|
-
},
|
|
86
|
+
const greeting = await apps.bar.tools.greet({ name: "world" }, {
|
|
87
|
+
elicit: async ({ message }) => ({ action: "accept", content: await ask(message) }),
|
|
89
88
|
});
|
|
90
|
-
|
|
91
|
-
const greeting = await apps.bar.tools.greet({ name: "world" });
|
|
92
89
|
```
|
|
93
90
|
|
|
94
|
-
|
|
91
|
+
Without `elicit`, the question arrives on `ToolCallError.result`, or as an `input_required` result from `callTool`. To answer later, for example in another request, call again with `inputResponses` and `requestState`.
|
|
95
92
|
|
|
96
93
|
</details>
|
|
97
94
|
|
|
98
95
|
<details open>
|
|
99
96
|
<summary><h3>Read a resource</h3></summary>
|
|
100
97
|
|
|
101
|
-
A provider types a JSON resource by registering it with `mimeType: "application/json"` and an `"sh.holon/outputSchema"` JSON Schema in its `_meta
|
|
98
|
+
A provider types a JSON resource by registering it with `mimeType: "application/json"` and an `"sh.holon/outputSchema"` JSON Schema in its `_meta`. The client then offers `read` and `watch`:
|
|
102
99
|
|
|
103
100
|
```ts
|
|
104
101
|
const summary = await apps.orders.resources["orders://summary"].read();
|
|
@@ -113,16 +110,17 @@ await apps.orders.resources["orders://summary"].watch(value => console.log(value
|
|
|
113
110
|
|
|
114
111
|
</details>
|
|
115
112
|
|
|
116
|
-
##
|
|
113
|
+
## Identify the caller
|
|
114
|
+
|
|
115
|
+
Inside a tool callback, `getContext(ctx.http).principal` is the user Holon admitted for the request, or `null` for anonymous requests. A `createMcpHandler` factory can pass its `McpRequestContext` instead.
|
|
117
116
|
|
|
118
117
|
```ts
|
|
119
|
-
import {
|
|
118
|
+
import { getContext } from "holon-sh/context";
|
|
120
119
|
|
|
121
|
-
server.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
120
|
+
server.registerTool("whoami", {}, async ctx => {
|
|
121
|
+
const { principal } = getContext(ctx.http);
|
|
122
|
+
if (!principal) throw new Error("Sign in first.");
|
|
123
|
+
principal.claims.email; // standard OpenID claims are typed: string | undefined
|
|
124
|
+
return { content: [{ type: "text", text: `${principal.subject} at ${principal.issuer}` }] };
|
|
125
|
+
});
|
|
126
126
|
```
|
|
127
|
-
|
|
128
|
-
The page itself uses [`holon-ui`](https://github.com/holon-sh/sdk-ui).
|
package/context.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Standard OpenID Connect address claims. */
|
|
2
|
+
export interface AddressClaims {
|
|
3
|
+
formatted?: string; street_address?: string; locality?: string; region?: string; postal_code?: string; country?: string;
|
|
4
|
+
[claim: string]: unknown;
|
|
5
|
+
}
|
|
6
|
+
/** Standard OpenID Connect claims. Holon drops one whose value has the wrong type; other claims pass through as JSON. */
|
|
7
|
+
export interface IdentityClaims {
|
|
8
|
+
name?: string; given_name?: string; family_name?: string; middle_name?: string; nickname?: string;
|
|
9
|
+
preferred_username?: string; profile?: string; picture?: string; website?: string; email?: string;
|
|
10
|
+
email_verified?: boolean; gender?: string; birthdate?: string; zoneinfo?: string; locale?: string;
|
|
11
|
+
phone_number?: string; phone_number_verified?: boolean; address?: AddressClaims; updated_at?: number;
|
|
12
|
+
[claim: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
/** The user a request acts for, as Holon admitted it. */
|
|
15
|
+
export interface Principal { issuer: string; subject: string; clientId: string; scopes: string[]; claims: IdentityClaims; actor?: Record<string, unknown> }
|
|
16
|
+
/** The caller of the current MCP request. principal is null for anonymous requests. */
|
|
17
|
+
export interface RequestContext { principal: Principal | null }
|
|
18
|
+
/** Read the caller from the context an MCP server receives: a tool callback's ctx.http, or the McpRequestContext of a createMcpHandler factory. */
|
|
19
|
+
export declare function getContext(context?: { authInfo?: { clientId: string; scopes: string[]; extra?: Record<string, unknown> } }): RequestContext;
|
package/context.mjs
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Holon admits the caller before the request reaches the server and hands it over as MCP's authInfo.
|
|
2
|
+
export function getContext({ authInfo } = {}) {
|
|
3
|
+
const { iss, sub, claims, act } = authInfo?.extra ?? {};
|
|
4
|
+
if (typeof iss !== "string" || typeof sub !== "string") return { principal: null };
|
|
5
|
+
return { principal: { issuer: iss, subject: sub, clientId: authInfo.clientId, scopes: authInfo.scopes, claims: claims ?? {}, ...(act && { actor: act }) } };
|
|
6
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "holon-sh",
|
|
3
|
-
"version": "0.0.1-dev.
|
|
4
|
-
"description": "Typed MCP clients and
|
|
3
|
+
"version": "0.0.1-dev.7",
|
|
4
|
+
"description": "Typed MCP clients and the caller of an MCP request, for Node.js.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/holon-sh/sdk-ts.git",
|
|
@@ -16,21 +16,16 @@
|
|
|
16
16
|
"access": "public"
|
|
17
17
|
},
|
|
18
18
|
"holon": {
|
|
19
|
-
"
|
|
20
|
-
"contractVersion": 1
|
|
19
|
+
"contractVersion": 2
|
|
21
20
|
},
|
|
22
21
|
"exports": {
|
|
23
|
-
".": {
|
|
24
|
-
"types": "./generated/index.d.ts",
|
|
25
|
-
"import": "./generated/index.js"
|
|
26
|
-
},
|
|
27
22
|
"./runtime": {
|
|
28
23
|
"types": "./runtime.d.ts",
|
|
29
24
|
"import": "./runtime.mjs"
|
|
30
25
|
},
|
|
31
|
-
"./
|
|
32
|
-
"types": "./
|
|
33
|
-
"import": "./
|
|
26
|
+
"./context": {
|
|
27
|
+
"types": "./context.d.ts",
|
|
28
|
+
"import": "./context.mjs"
|
|
34
29
|
},
|
|
35
30
|
"./protocol": {
|
|
36
31
|
"types": "./protocol.d.ts",
|
|
@@ -42,19 +37,14 @@
|
|
|
42
37
|
"runtime.d.ts",
|
|
43
38
|
"validate.mjs",
|
|
44
39
|
"protocol.d.ts",
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"generated/",
|
|
48
|
-
"README.md"
|
|
40
|
+
"context.mjs",
|
|
41
|
+
"context.d.ts"
|
|
49
42
|
],
|
|
50
43
|
"scripts": {
|
|
51
44
|
"build": "node scripts/build.mjs",
|
|
52
|
-
"prepack": "npm run build"
|
|
53
|
-
"docs:build": "node scripts/build-reference.mjs"
|
|
45
|
+
"prepack": "npm run build"
|
|
54
46
|
},
|
|
55
47
|
"dependencies": {
|
|
56
|
-
"@modelcontextprotocol/client": "^2.3.1"
|
|
57
|
-
"ajv": "^8.20.0",
|
|
58
|
-
"ajv-formats": "^3.0.1"
|
|
48
|
+
"@modelcontextprotocol/client": "^2.3.1"
|
|
59
49
|
}
|
|
60
50
|
}
|
package/protocol.d.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import type { CallToolRequest, CallToolResult,
|
|
1
|
+
import type { CallToolRequest, CallToolResult, ElicitRequestParams, ElicitResult, InputRequiredResult, InputResponses, ListToolsResult, RequestOptions } from "@modelcontextprotocol/client";
|
|
2
2
|
|
|
3
3
|
/** Bearer-token or OAuth provider accepted by the MCP transport. */
|
|
4
4
|
export type AuthProvider =
|
|
5
5
|
| { token(): Promise<string | undefined>; onUnauthorized?(context: unknown): Promise<void> }
|
|
6
6
|
| { tokens(): unknown; redirectUrl: unknown; clientMetadata: unknown };
|
|
7
|
-
/**
|
|
8
|
-
export type
|
|
9
|
-
/** Answers a server's elicitation so the call that asked can complete. */
|
|
10
|
-
export type ElicitHandler = (request: ElicitRequest, context: ClientContext) => ElicitResult | Promise<ElicitResult>;
|
|
7
|
+
/** Answers a server's question so the call that asked can complete. `params.mode` tells a form from a URL to open. */
|
|
8
|
+
export type ElicitHandler = (params: ElicitRequestParams) => ElicitResult | Promise<ElicitResult>;
|
|
11
9
|
|
|
12
|
-
/** MCP request options, including AbortSignal and timeout in milliseconds. Input-required responses are enabled by the runtime. */
|
|
10
|
+
/** MCP request options, including AbortSignal and timeout in milliseconds. A request without a timeout waits for its answer; progress from the server restarts a timeout. Input-required responses are enabled by the runtime. */
|
|
13
11
|
export type CallOptions = Omit<RequestOptions, "allowInputRequired">;
|
|
12
|
+
/** Call options for a tool. `elicit` answers its questions in place, so the call returns the completed result. */
|
|
13
|
+
export type ToolCallOptions = CallOptions & { elicit?: ElicitHandler };
|
|
14
14
|
/** MCP request metadata and input continuation fields. Pass inputResponses and requestState when continuing an input-required result. */
|
|
15
15
|
export type CallMetadata = Omit<CallToolRequest["params"], "name" | "arguments"> & { inputResponses?: InputResponses; requestState?: string };
|
|
16
16
|
/** @internal */
|
|
@@ -23,9 +23,13 @@ export type ToolResult<Output> =
|
|
|
23
23
|
| (Omit<Fields<CallToolResult>, "structuredContent" | "isError"> & { resultType: "complete"; isError: true; structuredContent?: unknown })
|
|
24
24
|
| InputRequiredResult;
|
|
25
25
|
|
|
26
|
-
/** Error thrown by the tools convenience methods for error or input_required results.
|
|
26
|
+
/** Error thrown by the tools convenience methods for error or input_required results. A failure's message carries its text; `data` is what the server said in a form to branch on. Pass elicit to answer questions in place, or continue later through callTool. */
|
|
27
27
|
export declare class ToolCallError extends Error {
|
|
28
28
|
readonly result: Extract<ToolResult<unknown>, { isError: true } | { resultType: "input_required" }>;
|
|
29
|
+
/** The failure's text content, its text blocks joined by newlines. Undefined for questions left unanswered. */
|
|
30
|
+
readonly text?: string;
|
|
31
|
+
/** The failure's structured content, or else its text parsed as JSON when that text is JSON. Its shape is the server's own. */
|
|
32
|
+
readonly data?: unknown;
|
|
29
33
|
constructor(app: string, tool: string, result: ToolCallError["result"]);
|
|
30
34
|
}
|
|
31
35
|
|
|
@@ -40,8 +44,8 @@ export interface ToolClient<I, O, R> {
|
|
|
40
44
|
readonly read: (options?: CallOptions) => Promise<R[U]>;
|
|
41
45
|
readonly watch: (onChange: (value: R[U]) => void | Promise<void>, options?: { signal?: AbortSignal }) => Promise<void>;
|
|
42
46
|
} };
|
|
43
|
-
/** Convenience methods returning structured output, or the complete result for a tool without an output schema. Error results throw ToolCallError, as do
|
|
44
|
-
readonly tools: { readonly [K in Exclude<keyof I, "then" | "toJSON" | "toString" | "valueOf" | "toLocaleString">]: (input: I[K], options?:
|
|
45
|
-
/** Return the full MCP result, preserving error results and
|
|
46
|
-
readonly callTool: <K extends keyof I & string>(request: CallMetadata & { name: K; arguments: I[K] }, options?:
|
|
47
|
+
/** Convenience methods returning structured output, or the complete MCP result, with its content blocks typed, for a tool without an output schema. Error results throw ToolCallError, as do questions left unanswered. Reserved object method names remain callable through callTool. */
|
|
48
|
+
readonly tools: { readonly [K in Exclude<keyof I, "then" | "toJSON" | "toString" | "valueOf" | "toLocaleString">]: (input: I[K], options?: ToolCallOptions) => Promise<K extends keyof O ? O[K] : CompleteResult<unknown>> };
|
|
49
|
+
/** Return the full MCP result, preserving error results and questions left unanswered. Successful output is contract-validated; the server validates arguments. */
|
|
50
|
+
readonly callTool: <K extends keyof I & string>(request: CallMetadata & { name: K; arguments: I[K] }, options?: ToolCallOptions) => Promise<ToolResult<K extends keyof O ? O[K] : unknown>>;
|
|
47
51
|
}
|
package/runtime.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { AuthProvider, CallOptions,
|
|
1
|
+
import type { AuthProvider, CallOptions, ToolClient } from "./protocol.js";
|
|
2
2
|
export { ToolCallError } from "./protocol.js";
|
|
3
|
-
export type { AuthProvider, CallOptions, CallMetadata,
|
|
3
|
+
export type { AuthProvider, CallOptions, CallMetadata, ElicitHandler, ToolCallOptions, ToolResult, ToolClient } from "./protocol.js";
|
|
4
4
|
|
|
5
5
|
/** Connection settings for an MCP endpoint. */
|
|
6
6
|
export interface Binding {
|
|
@@ -14,10 +14,6 @@ export interface Binding {
|
|
|
14
14
|
installationUID?: string;
|
|
15
15
|
/** Close the session after every call instead of keeping one connection open. */
|
|
16
16
|
scoped?: boolean;
|
|
17
|
-
/** Client capabilities declared to the server. On protocol 2026-07-28 every request carries them in `_meta["io.modelcontextprotocol/clientCapabilities"]` ([spec](https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#capabilities)); declare `{ elicitation: { form: {} } }` to receive form elicitations in input-required results, or set `elicit` to answer them. */
|
|
18
|
-
capabilities?: ClientCapabilities;
|
|
19
|
-
/** Answers elicitations in place, so `tools.x` and `callTool` return the completed result. Declares `{ elicitation: { form: {} } }` unless `capabilities` sets `elicitation`. */
|
|
20
|
-
elicit?: ElicitHandler;
|
|
21
17
|
}
|
|
22
18
|
/** Alias for an endpoint binding. */
|
|
23
19
|
export type EndpointBinding = Binding;
|
|
@@ -45,9 +41,7 @@ export interface CreateOptions<B = Binding> {
|
|
|
45
41
|
/** Session for a binding the runtime does not connect to itself (anything without an endpoint). */
|
|
46
42
|
session?: (app: string, binding: B) => ToolSession | undefined;
|
|
47
43
|
}
|
|
48
|
-
/** Construct runtime clients from identity and contracts. Connections open lazily. Explicit and resolved endpoint bindings reuse one session unless scoped is true; <NAME>_MCP_URL bindings use one session per call. Close persistent clients on shutdown. Most callers use the generated
|
|
44
|
+
/** Construct runtime clients from identity and contracts. Connections open lazily. Explicit and resolved endpoint bindings reuse one session unless scoped is true; <NAME>_MCP_URL bindings use one session per call. Close persistent clients on shutdown. Most callers use the generated clients. */
|
|
49
45
|
export declare function createApps<B = Binding>(identity: Identity, contracts: Record<string, Contract | null>, bindings?: Record<string, B>, options?: CreateOptions<B>): Readonly<Record<string, RuntimeClient>>;
|
|
50
46
|
/** Construct only the explicitly bound dependencies. Unknown names throw; an empty binding map selects none. Used by generated createApps factories. */
|
|
51
47
|
export declare function bindApps<B = Binding>(identity: Identity, contracts: Record<string, Contract | null>, bindings: Record<string, B>, options?: CreateOptions<B>): Readonly<Record<string, RuntimeClient>>;
|
|
52
|
-
/** For hosts: register a process-wide resolver for clients created without explicit bindings. Explicit bindings take precedence; return undefined to fall back to the <NAME>_MCP_URL environment variable. Register during startup. */
|
|
53
|
-
export declare function setDefaultBinding(resolve: (app: string) => Binding | undefined): void;
|
package/runtime.mjs
CHANGED
|
@@ -1,32 +1,37 @@
|
|
|
1
|
-
import { Client,
|
|
1
|
+
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
|
|
2
2
|
import { validator as defaultValidator } from "./validate.mjs";
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export function setDefaultBinding(resolve) {
|
|
9
|
-
if (typeof resolve !== "function") throw new TypeError("Binding resolver must be a function.");
|
|
10
|
-
globalThis[DEFAULT_BINDING] = resolve;
|
|
11
|
-
}
|
|
4
|
+
// Environment-bound calls reuse the last server/discover result for the same endpoint and token instead of discovering
|
|
5
|
+
// the server on every call.
|
|
6
|
+
const DISCOVERY = Symbol("discovery");
|
|
7
|
+
const discovered = new Map();
|
|
12
8
|
|
|
13
9
|
function environment(identity, name) {
|
|
14
10
|
if (!Object.hasOwn(identity.requires ?? {}, name)) return undefined;
|
|
15
|
-
const
|
|
16
|
-
const endpoint = process.env[
|
|
17
|
-
// Environment-bound dependencies connect for one call at a time.
|
|
18
|
-
|
|
19
|
-
|
|
11
|
+
const prefix = `${name.toUpperCase().replaceAll("-", "_")}_MCP_`;
|
|
12
|
+
const endpoint = process.env[`${prefix}URL`];
|
|
13
|
+
// Environment-bound dependencies connect for one call at a time, sending <NAME>_MCP_TOKEN as the bearer token when it is set.
|
|
14
|
+
const token = process.env[`${prefix}TOKEN`];
|
|
15
|
+
if (endpoint) return { endpoint, scoped: true, [DISCOVERY]: `${endpoint} ${token ?? ""}`, ...(token && { authProvider: { token: async () => token } }) };
|
|
16
|
+
throw new Error(`Dependency ${name} is unavailable (${prefix}URL). Provide a binding or set the environment variable.`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function json(text) {
|
|
20
|
+
try { return JSON.parse(text); } catch { return undefined; }
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export class ToolCallError extends Error {
|
|
23
24
|
constructor(app, tool, result) {
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
const asking = result.resultType === "input_required";
|
|
26
|
+
const text = result.content?.filter(part => part.type === "text").map(part => part.text).join("\n") || undefined;
|
|
27
|
+
const detail = asking
|
|
28
|
+
? Object.values(result.inputRequests ?? {}).flatMap(({ params }) => [params?.message, params?.url]).filter(Boolean).join(" ")
|
|
29
|
+
: text;
|
|
30
|
+
super(`${app}.${tool}: ${asking ? "additional input required" : "tool failed"}${detail ? `: ${detail}` : ""}`);
|
|
28
31
|
this.name = "ToolCallError";
|
|
29
32
|
this.result = result;
|
|
33
|
+
this.text = text;
|
|
34
|
+
this.data = result.structuredContent ?? json(text);
|
|
30
35
|
}
|
|
31
36
|
}
|
|
32
37
|
|
|
@@ -43,6 +48,14 @@ function untilAborted(promise, signal) {
|
|
|
43
48
|
});
|
|
44
49
|
}
|
|
45
50
|
|
|
51
|
+
// The MCP client ends a request after 60 s unless given a timeout. A request without one waits as long as a timer can
|
|
52
|
+
// (2^31 - 1 ms, about 24.8 days). A request with one asks for progress, and progress restarts its timeout.
|
|
53
|
+
function patient(options) {
|
|
54
|
+
return options?.timeout === undefined
|
|
55
|
+
? { ...options, timeout: 2 ** 31 - 1 }
|
|
56
|
+
: { onprogress: () => {}, resetTimeoutOnProgress: true, ...options };
|
|
57
|
+
}
|
|
58
|
+
|
|
46
59
|
function delay(milliseconds, signal) {
|
|
47
60
|
return new Promise(resolve => {
|
|
48
61
|
const timer = setTimeout(resolve, milliseconds);
|
|
@@ -59,7 +72,7 @@ export function bindApps(identity, contracts, bindings, options) {
|
|
|
59
72
|
}
|
|
60
73
|
|
|
61
74
|
export function createApps(identity, contracts, bindings, options) {
|
|
62
|
-
const { validator = defaultValidator, resolve = name =>
|
|
75
|
+
const { validator = defaultValidator, resolve = name => environment(identity, name), session: hosted } = options ?? {};
|
|
63
76
|
const apps = Object.create(null);
|
|
64
77
|
// Without explicit bindings, every declared dependency gets a client; those without a contract are untyped.
|
|
65
78
|
const members = { ...(bindings ? {} : Object.fromEntries(Object.keys(identity.requires ?? {}).map(name => [name, null]))), ...contracts };
|
|
@@ -68,18 +81,21 @@ export function createApps(identity, contracts, bindings, options) {
|
|
|
68
81
|
// Without explicit bindings, the resolve hook supplies the binding on every call.
|
|
69
82
|
const current = () => binding ?? (bindings ? undefined : resolve(name));
|
|
70
83
|
let connection;
|
|
71
|
-
const connect = resolved => {
|
|
84
|
+
const connect = (resolved, discover) => {
|
|
85
|
+
// Every client can be asked: a call answers with elicit, or the question reaches its caller. Servers must not assume a client answers: https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#server-requirements-basic-workflow
|
|
72
86
|
// On 2026-07-28 the client sends these capabilities in every request's _meta: https://github.com/modelcontextprotocol/typescript-sdk/blob/fcef852f6cdafea07f36cf6fae69b37c8adf85be/packages/client/src/client/client.ts#L700-L711
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
87
|
+
const client = new Client({ name: identity.name, version: identity.version }, { capabilities: { elicitation: { form: {}, url: {} } }, versionNegotiation: { mode: "auto" } });
|
|
88
|
+
const transport = new StreamableHTTPClientTransport(new URL(resolved.endpoint), { authProvider: resolved.authProvider, fetch: async (input, init) => {
|
|
89
|
+
const response = await (resolved.fetch ?? fetch)(input, init);
|
|
90
|
+
current.answered ||= response.ok;
|
|
91
|
+
return response;
|
|
92
|
+
}, requestInit: { credentials: "include", redirect: "error", headers: resolved.installationUID ? { "X-Holon-Installation-UID": resolved.installationUID } : {} } });
|
|
77
93
|
const current = { client, close: async () => {
|
|
78
94
|
await transport.close();
|
|
79
95
|
await current.ready.catch(() => {});
|
|
80
96
|
} };
|
|
81
97
|
transport.onclose = () => { if (connection === current) connection = undefined; };
|
|
82
|
-
current.ready = client.connect(transport).catch(async error => {
|
|
98
|
+
current.ready = client.connect(transport, discover && { prior: { kind: "modern", discover } }).catch(async error => {
|
|
83
99
|
await transport.close();
|
|
84
100
|
throw error;
|
|
85
101
|
});
|
|
@@ -97,41 +113,47 @@ export function createApps(identity, contracts, bindings, options) {
|
|
|
97
113
|
if (!resolved.endpoint) throw new Error(`Dependency ${name} binding has no endpoint.`);
|
|
98
114
|
// Bindings marked scoped connect for one call at a time.
|
|
99
115
|
const scoped = resolved.scoped === true;
|
|
100
|
-
const
|
|
116
|
+
const key = resolved[DISCOVERY];
|
|
117
|
+
const prior = discovered.get(key);
|
|
118
|
+
const active = scoped ? connect(resolved, prior) : (connection ??= connect(resolved));
|
|
101
119
|
try {
|
|
102
120
|
await untilAborted(active.ready, options?.signal);
|
|
103
|
-
|
|
121
|
+
if (key) discovered.set(key, active.client.getDiscoverResult());
|
|
122
|
+
return await run(active.client);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
// A server that no longer speaks the discovered protocol rejects the first request without running it.
|
|
125
|
+
if (!prior || active.answered || error?.status !== 400) throw error;
|
|
126
|
+
discovered.delete(key);
|
|
127
|
+
return session(options, run);
|
|
104
128
|
} finally { if (scoped) await active.close(); }
|
|
105
129
|
};
|
|
106
130
|
const tools = Object.create(null);
|
|
107
131
|
const definitions = new Map((contract?.tools ?? []).map(definition => [definition.name, { definition }]));
|
|
108
|
-
const callTool = async (request, options) => {
|
|
132
|
+
const callTool = async (request, { elicit, ...options } = {}) => {
|
|
109
133
|
const tool = definitions.get(request.name);
|
|
110
134
|
if (!tool && contract) throw new Error(`Unknown tool ${name}.${request.name}.`);
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
})).catch(error => {
|
|
125
|
-
if (error instanceof MissingRequiredClientCapabilityError) error.message = `${name}.${request.name}: ${error.message}. Set elicit or capabilities on the binding.`;
|
|
126
|
-
throw error;
|
|
135
|
+
const result = await session(options, async client => {
|
|
136
|
+
const send = request => client.callTool(request, {
|
|
137
|
+
...patient(options), allowInputRequired: true,
|
|
138
|
+
...(tool && { toolDefinition: { ...tool.definition, outputSchema: undefined } }),
|
|
139
|
+
});
|
|
140
|
+
let result = await send(request);
|
|
141
|
+
// A server may keep asking, so answering stops after ten rounds, the MCP clients' own limit, and the last question reaches the caller.
|
|
142
|
+
for (let round = 0; elicit && result.resultType === "input_required" && round < 10; round++) {
|
|
143
|
+
const inputResponses = {};
|
|
144
|
+
for (const [key, { params }] of Object.entries(result.inputRequests ?? {})) inputResponses[key] = await elicit(params);
|
|
145
|
+
result = await send({ ...request, inputResponses, requestState: result.requestState });
|
|
146
|
+
}
|
|
147
|
+
return result;
|
|
127
148
|
});
|
|
128
149
|
if (result.resultType === "input_required") return result;
|
|
129
|
-
|
|
130
|
-
|
|
150
|
+
// The server validates arguments; the client checks the output against the contract it was compiled against.
|
|
151
|
+
if (!result.isError && tool?.definition.outputSchema) {
|
|
152
|
+
validate(tool.output ??= validator(tool.definition.outputSchema), result.structuredContent, `structured output from ${name}.${request.name}`);
|
|
131
153
|
}
|
|
132
154
|
return { ...result, resultType: "complete" };
|
|
133
155
|
};
|
|
134
|
-
const listTools = options => session(options, client => client.listTools(undefined, options));
|
|
156
|
+
const listTools = options => session(options, client => client.listTools(undefined, patient(options)));
|
|
135
157
|
for (const tool of contract?.tools ?? []) if (!["then", "toJSON", "toString", "valueOf", "toLocaleString"].includes(tool.name)) {
|
|
136
158
|
tools[tool.name] = async (input, options) => {
|
|
137
159
|
const result = await callTool({ name: tool.name, arguments: input }, options);
|
|
@@ -148,7 +170,7 @@ export function createApps(identity, contracts, bindings, options) {
|
|
|
148
170
|
validate(valid, value, `contents from ${name} ${resource.uri}`);
|
|
149
171
|
return value;
|
|
150
172
|
};
|
|
151
|
-
const read = async options => parse(await session(options, client => client.readResource({ uri: resource.uri }, options)));
|
|
173
|
+
const read = async options => parse(await session(options, client => client.readResource({ uri: resource.uri }, patient(options))));
|
|
152
174
|
const watch = async (onChange, { signal } = {}) => {
|
|
153
175
|
const resolved = current();
|
|
154
176
|
if (!resolved?.endpoint) throw new Error(`Watching ${name} ${resource.uri} requires an endpoint binding.`);
|
|
@@ -160,7 +182,7 @@ export function createApps(identity, contracts, bindings, options) {
|
|
|
160
182
|
const { client } = current;
|
|
161
183
|
if (!client.getServerCapabilities()?.resources?.subscribe) throw new Error(`${name} does not advertise resource subscriptions.`);
|
|
162
184
|
let reads = Promise.resolve();
|
|
163
|
-
const refresh = () => (reads = reads.then(async () => onChange(parse(await client.readResource({ uri: resource.uri }, { signal, cacheMode: "bypass" }))))
|
|
185
|
+
const refresh = () => (reads = reads.then(async () => onChange(parse(await client.readResource({ uri: resource.uri }, patient({ signal, cacheMode: "bypass" })))))
|
|
164
186
|
.catch(error => { failure ??= error; return current.close(); }));
|
|
165
187
|
client.setNotificationHandler("notifications/resources/updated", ({ params }) => { if (params.uri === resource.uri) void refresh(); });
|
|
166
188
|
const subscription = await client.listen({ resourceSubscriptions: [resource.uri] }, { signal });
|
package/validate.mjs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
3
|
-
import addFormats from "ajv-formats";
|
|
1
|
+
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv";
|
|
4
2
|
|
|
3
|
+
// The MCP client's own Ajv, one engine per schema so contracts that reuse an $id stay apart.
|
|
5
4
|
export function validator(schema) {
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
const validate = new AjvJsonSchemaValidator().getValidator(schema);
|
|
6
|
+
const check = value => {
|
|
7
|
+
const { valid, errorMessage } = validate(value);
|
|
8
|
+
check.errors = errorMessage;
|
|
9
|
+
return valid;
|
|
10
|
+
};
|
|
11
|
+
return check;
|
|
10
12
|
}
|
package/generated/index.d.ts
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
// holon-generated-apps-v1 placeholder
|
|
2
|
-
export type Apps = Record<never, never>;
|
|
3
|
-
export type Inputs = Record<never, never>;
|
|
4
|
-
export type Outputs = Record<never, never>;
|
|
5
|
-
export type InputTypes = Inputs;
|
|
6
|
-
export type OutputTypes = Outputs;
|
|
7
|
-
export type Resources = Record<never, never>;
|
|
8
|
-
export { ToolCallError } from "../protocol.js";
|
|
9
|
-
export type { Binding, EndpointBinding, CreateOptions } from "../runtime.js";
|
|
10
|
-
import type { CreateOptions } from "../runtime.js";
|
|
11
|
-
export declare function createApps(bindings: Record<string, never>, options?: CreateOptions): Apps;
|
|
12
|
-
export declare const apps: Apps;
|
package/generated/index.js
DELETED
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
// holon-generated-apps-v1 placeholder
|
|
2
|
-
import {createApps as create} from "../runtime.mjs";
|
|
3
|
-
export {ToolCallError} from "../runtime.mjs";
|
|
4
|
-
export const apps = create({name:"unconfigured",version:"0",requires:{}}, {});
|
|
5
|
-
export function createApps(bindings, options) { if (Object.keys(bindings).length) throw new Error("Run holon types generate to configure app bindings."); return apps; }
|
package/view.d.ts
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
/** MIME type of an MCP App HTML resource. */
|
|
2
|
-
export declare const VIEW_MIME_TYPE = "text/html;profile=mcp-app";
|
|
3
|
-
/** Allowed network and asset domains for the view content security policy. */
|
|
4
|
-
export interface ViewOptions {
|
|
5
|
-
csp?: {
|
|
6
|
-
connectDomains?: string[];
|
|
7
|
-
resourceDomains?: string[];
|
|
8
|
-
};
|
|
9
|
-
}
|
|
10
|
-
type ViewRegistration = [
|
|
11
|
-
name: string,
|
|
12
|
-
uri: string,
|
|
13
|
-
metadata: {
|
|
14
|
-
mimeType: string;
|
|
15
|
-
_meta: {
|
|
16
|
-
ui: {
|
|
17
|
-
csp: {
|
|
18
|
-
connectDomains: string[];
|
|
19
|
-
resourceDomains: string[];
|
|
20
|
-
};
|
|
21
|
-
};
|
|
22
|
-
};
|
|
23
|
-
},
|
|
24
|
-
read: () => Promise<{
|
|
25
|
-
ttlMs: number;
|
|
26
|
-
cacheScope: "private";
|
|
27
|
-
contents: {
|
|
28
|
-
uri: string;
|
|
29
|
-
mimeType: string;
|
|
30
|
-
text: string;
|
|
31
|
-
}[];
|
|
32
|
-
}>
|
|
33
|
-
];
|
|
34
|
-
/**
|
|
35
|
-
* Create a registration tuple for server.registerResource(...appView(...)).
|
|
36
|
-
* Node only: the HTML file is read once when this helper is called.
|
|
37
|
-
* Resource reads reuse that promise; file read failures reject resource requests.
|
|
38
|
-
* @param name Human-readable resource name.
|
|
39
|
-
* @param uri Resource URI, usually starting with ui://.
|
|
40
|
-
* @param file Built HTML file path or file URL.
|
|
41
|
-
* @param options View content security policy.
|
|
42
|
-
* @returns Name, URI, metadata, and resource-read callback.
|
|
43
|
-
* @example
|
|
44
|
-
* server.registerResource(...appView("App", "ui://app/index.html", new URL("./index.html", import.meta.url)));
|
|
45
|
-
*/
|
|
46
|
-
export declare function appView(name: string, uri: string, file: URL | string, options?: ViewOptions): ViewRegistration;
|
|
47
|
-
export {};
|
package/view.js
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
-
/** MIME type of an MCP App HTML resource. */
|
|
3
|
-
export const VIEW_MIME_TYPE = "text/html;profile=mcp-app";
|
|
4
|
-
/**
|
|
5
|
-
* Create a registration tuple for server.registerResource(...appView(...)).
|
|
6
|
-
* Node only: the HTML file is read once when this helper is called.
|
|
7
|
-
* Resource reads reuse that promise; file read failures reject resource requests.
|
|
8
|
-
* @param name Human-readable resource name.
|
|
9
|
-
* @param uri Resource URI, usually starting with ui://.
|
|
10
|
-
* @param file Built HTML file path or file URL.
|
|
11
|
-
* @param options View content security policy.
|
|
12
|
-
* @returns Name, URI, metadata, and resource-read callback.
|
|
13
|
-
* @example
|
|
14
|
-
* server.registerResource(...appView("App", "ui://app/index.html", new URL("./index.html", import.meta.url)));
|
|
15
|
-
*/
|
|
16
|
-
export function appView(name, uri, file, options = {}) {
|
|
17
|
-
const csp = { connectDomains: [], resourceDomains: [], ...options.csp };
|
|
18
|
-
const page = readFile(file, "utf8");
|
|
19
|
-
page.catch(() => { });
|
|
20
|
-
return [
|
|
21
|
-
name,
|
|
22
|
-
uri,
|
|
23
|
-
{ mimeType: VIEW_MIME_TYPE, _meta: { ui: { csp } } },
|
|
24
|
-
async () => ({
|
|
25
|
-
ttlMs: 0,
|
|
26
|
-
cacheScope: "private",
|
|
27
|
-
contents: [{ uri, mimeType: VIEW_MIME_TYPE, text: await page }],
|
|
28
|
-
}),
|
|
29
|
-
];
|
|
30
|
-
}
|