purra-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +247 -0
- package/package.json +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lybrands
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# purra-mcp (TypeScript)
|
|
2
|
+
|
|
3
|
+
Development package for explicitly authorized, read-only MCP tools. Requires
|
|
4
|
+
Node.js 22+, matching `purra`, and the pinned official
|
|
5
|
+
`@modelcontextprotocol/sdk@1.30.0` (v1 line). Core has no MCP dependency.
|
|
6
|
+
Build Core and this package, then install both in the consuming application.
|
|
7
|
+
|
|
8
|
+
The application owns the connected official `Client`, transport, credentials,
|
|
9
|
+
initialization and shutdown. The adapter sends only paginated `tools/list` and
|
|
10
|
+
single `tools/call` requests. Configure monitoring before connecting:
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
14
|
+
import { ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
15
|
+
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
16
|
+
import { McpCatalogMonitor, discoverMcpTools } from "purra-mcp";
|
|
17
|
+
|
|
18
|
+
async function attach(transport: Transport) {
|
|
19
|
+
const monitor = new McpCatalogMonitor();
|
|
20
|
+
const setVersion = transport.setProtocolVersion?.bind(transport);
|
|
21
|
+
transport.setProtocolVersion = version => {
|
|
22
|
+
setVersion?.(version);
|
|
23
|
+
monitor.acceptProtocolVersion(version);
|
|
24
|
+
};
|
|
25
|
+
const client = new Client({ name: "my-host", version: "1" });
|
|
26
|
+
client.setNotificationHandler(ToolListChangedNotificationSchema,
|
|
27
|
+
notification => monitor.onNotification(notification));
|
|
28
|
+
client.onclose = () => monitor.close();
|
|
29
|
+
await client.connect(transport);
|
|
30
|
+
const catalog = await discoverMcpTools(client, "documents", {
|
|
31
|
+
"remote.search": {
|
|
32
|
+
localName: "search_documents",
|
|
33
|
+
policy: { mode: "read", title: "Search documents" },
|
|
34
|
+
scope: (args, context) => context.runId ? undefined : "Missing Run",
|
|
35
|
+
concurrencySafe: false,
|
|
36
|
+
},
|
|
37
|
+
}, { monitor });
|
|
38
|
+
// Pass catalog.registrations as Agent tools while client remains connected.
|
|
39
|
+
// Replace the sample scope check with the application's real authorization.
|
|
40
|
+
return { client, catalog, monitor }; // Caller eventually awaits client.close().
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Compose existing notification/close handlers rather than replacing host logic.
|
|
45
|
+
The protocol hook receives the actual handshake version from SDK `connect`;
|
|
46
|
+
never fill it with a configured guess. Supported negotiated versions are
|
|
47
|
+
`2025-06-18` and `2025-11-25`. A previously connected host must have retained its
|
|
48
|
+
negotiated version and fed all list-change notifications.
|
|
49
|
+
|
|
50
|
+
Each binding is a host assertion of read-only behavior and authorized scope.
|
|
51
|
+
Remote annotations grant no authority. `scope: null` explicitly opts out of an
|
|
52
|
+
additional host scope check. A function returns `undefined`/true to allow, or
|
|
53
|
+
false/a non-empty string to deny. Core supplies Run/root/agent/lease context.
|
|
54
|
+
`concurrencySafe` records a declaration and does not itself enable scheduling.
|
|
55
|
+
The adapter cannot make a falsely declared remote write read-only.
|
|
56
|
+
|
|
57
|
+
The immutable snapshot has `schemaVersion: 1`, `serverId`, `protocolVersion`,
|
|
58
|
+
`revisionDigest` and entries sorted by local name. Identity uses Core's
|
|
59
|
+
`purra.json-identity/v1` over these fields without `revisionDigest`; entries
|
|
60
|
+
include names, description, schemas, read policy and concurrency declaration.
|
|
61
|
+
A list-change notification makes old registrations stale. Discover again and
|
|
62
|
+
replace the host's catalog at a safe boundary. Registration never widens itself.
|
|
63
|
+
|
|
64
|
+
Selected schemas use Core's strict `purra.output-schema/v1` object profile;
|
|
65
|
+
unsupported schemas fail discovery without weakening. Arguments are not
|
|
66
|
+
coerced. Success contains `{ text: string[], structured: object | null }`.
|
|
67
|
+
An output schema requires valid `structuredContent`. Text that resembles JSON
|
|
68
|
+
does not satisfy it. Media, resource content/links, required tasks and `isError`
|
|
69
|
+
results fail closed. This adapter does not install sampling, elicitation,
|
|
70
|
+
resources or task handlers; the host must not enable them on this tools-only path.
|
|
71
|
+
|
|
72
|
+
Default `limits`: `maxPages: 32`, `maxTools: 256`, `maxCatalogBytes: 1048576`,
|
|
73
|
+
`maxSchemaBytes: 65536`, `maxDescriptionBytes: 8192`, `maxResultBytes: 65536`,
|
|
74
|
+
`maxContentBlocks: 128`, `timeoutMs: 30000`. Lower any limit, or raise timeout
|
|
75
|
+
up to 300000 ms. The result budget also bounds decoded argument objects; Core
|
|
76
|
+
adds depth/node/validation budgets. Hosts must separately bound transport
|
|
77
|
+
frames/bodies before SDK decoding.
|
|
78
|
+
|
|
79
|
+
Discovery throws sanitized `McpAdapterError`. Known tool failures produce an
|
|
80
|
+
empty envelope and `errorCode`: `mcp_invalid_arguments`, `mcp_scope_denied`,
|
|
81
|
+
`mcp_catalog_stale`, `mcp_connection_closed`, `mcp_timeout`,
|
|
82
|
+
`mcp_protocol_error`, `mcp_transport_error`, `mcp_tool_error`,
|
|
83
|
+
`mcp_result_invalid`, `mcp_result_unsupported`, or `mcp_result_too_large`.
|
|
84
|
+
Core's batch admission can fail earlier with Core argument/scope errors.
|
|
85
|
+
No remote error message is passed to the model. No adapter retries, implicit
|
|
86
|
+
rediscovery or hidden model calls occur.
|
|
87
|
+
|
|
88
|
+
An AbortSignal stops local waiting; the official SDK sends a best-effort
|
|
89
|
+
cancellation notification. It does not prove the remote task stopped. Late
|
|
90
|
+
results are discarded. `effectState: "not_started"` describes the declared
|
|
91
|
+
absence of host writes, not whether a network request was sent. Write tools are
|
|
92
|
+
outside this adapter's support.
|
|
93
|
+
|
|
94
|
+
Shared fixtures run against the official local client/server protocol, with a
|
|
95
|
+
JSON serialization boundary. The installed smoke also launches and closes a
|
|
96
|
+
separate stdio server. Third-party MCP services, real Providers and downstream
|
|
97
|
+
applications require separate validation. See the [MCP tools specification](https://modelcontextprotocol.io/specification/2025-11-25/server/tools)
|
|
98
|
+
and [cancellation specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation).
|
|
99
|
+
|
|
100
|
+
Core's separately configured argument and result limits also apply. A result
|
|
101
|
+
accepted by the adapter may still exceed a lower Core result limit and fail
|
|
102
|
+
with `tool_result_too_large`. Align both budgets in the host configuration.
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
Unsupported schema metadata is rejected as well as unsupported validation
|
|
106
|
+
keywords. For example, `default` and `x-fastmcp-wrap-result` are outside
|
|
107
|
+
`purra.output-schema/v1`: a selected tool using either fails discovery with
|
|
108
|
+
`mcp_schema_unsupported` before `tools/call`. The adapter never strips these
|
|
109
|
+
fields to make a remote tool appear supported. Select compatible tools explicitly;
|
|
110
|
+
unselected unsupported tools do not widen the catalog. A working MCP handshake
|
|
111
|
+
alone does not establish tool compatibility.
|
|
112
|
+
|
|
113
|
+
MCP input/output schemas may declare the root `$schema` as exactly
|
|
114
|
+
`https://json-schema.org/draft/2020-12/schema`. The adapter recognizes this
|
|
115
|
+
MCP dialect declaration, retains it in the immutable snapshot and revision
|
|
116
|
+
digest, and compiles the remaining constraints with `purra.output-schema/v1`.
|
|
117
|
+
The full remote schema, including the declaration, counts against the schema
|
|
118
|
+
byte limit. Other dialect URIs, nested declarations, references, and unknown
|
|
119
|
+
keywords remain unsupported; no schema document is fetched. This does not add
|
|
120
|
+
`$schema` to the Core structured-output profile or promise full JSON Schema
|
|
121
|
+
2020-12 support. See the [MCP schema rules](https://modelcontextprotocol.io/specification/2025-11-25/server/tools).
|
|
122
|
+
|
|
123
|
+
HTTP connection failures (including `fetch` transport `TypeError`) return
|
|
124
|
+
`mcp_transport_error`; SDK response validation failures remain
|
|
125
|
+
`mcp_result_invalid`. A lost response stream may remain pending until the
|
|
126
|
+
configured request timeout. The host must close the monitor when discarding a
|
|
127
|
+
failed session, then initialize a new session and explicitly rediscover tools
|
|
128
|
+
before resuming work. Transport recovery never authorizes new tools.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { type ServerNotification } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
import { AgentError, type JsonValue, type ToolDefinition, type ToolPolicy } from "purra";
|
|
4
|
+
export declare class McpAdapterError extends AgentError {
|
|
5
|
+
constructor(code: string);
|
|
6
|
+
}
|
|
7
|
+
export interface McpToolLimits {
|
|
8
|
+
readonly maxPages: number;
|
|
9
|
+
readonly maxTools: number;
|
|
10
|
+
readonly maxCatalogBytes: number;
|
|
11
|
+
readonly maxSchemaBytes: number;
|
|
12
|
+
readonly maxDescriptionBytes: number;
|
|
13
|
+
readonly maxResultBytes: number;
|
|
14
|
+
readonly maxContentBlocks: number;
|
|
15
|
+
readonly timeoutMs: number;
|
|
16
|
+
}
|
|
17
|
+
/** Feed negotiated protocol, list-change notifications and disconnects from the host. */
|
|
18
|
+
export declare class McpCatalogMonitor {
|
|
19
|
+
#private;
|
|
20
|
+
acceptProtocolVersion(version: string): void;
|
|
21
|
+
onNotification(notification: ServerNotification): void;
|
|
22
|
+
close(): void;
|
|
23
|
+
get revision(): number;
|
|
24
|
+
get protocolVersion(): string | undefined;
|
|
25
|
+
check(revision: number): void;
|
|
26
|
+
}
|
|
27
|
+
export interface McpToolBinding {
|
|
28
|
+
readonly localName: string;
|
|
29
|
+
readonly policy: ToolPolicy;
|
|
30
|
+
readonly scope: NonNullable<ToolDefinition["scope"]> | null;
|
|
31
|
+
readonly concurrencySafe?: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface McpToolEntry {
|
|
34
|
+
readonly remoteName: string;
|
|
35
|
+
readonly localName: string;
|
|
36
|
+
readonly description: string;
|
|
37
|
+
readonly inputSchema: Readonly<Record<string, JsonValue>>;
|
|
38
|
+
readonly outputSchema: Readonly<Record<string, JsonValue>> | null;
|
|
39
|
+
readonly policy: Readonly<{
|
|
40
|
+
mode: "read";
|
|
41
|
+
title: string;
|
|
42
|
+
riskLevel: "read";
|
|
43
|
+
}>;
|
|
44
|
+
readonly concurrencySafe: boolean;
|
|
45
|
+
}
|
|
46
|
+
export interface McpToolSnapshot {
|
|
47
|
+
readonly schemaVersion: 1;
|
|
48
|
+
readonly serverId: string;
|
|
49
|
+
readonly protocolVersion: string;
|
|
50
|
+
readonly revisionDigest: string;
|
|
51
|
+
readonly entries: readonly McpToolEntry[];
|
|
52
|
+
}
|
|
53
|
+
export interface McpToolCatalog {
|
|
54
|
+
readonly snapshot: McpToolSnapshot;
|
|
55
|
+
readonly registrations: readonly ToolDefinition[];
|
|
56
|
+
readonly stale: boolean;
|
|
57
|
+
}
|
|
58
|
+
export interface McpDiscoveryOptions {
|
|
59
|
+
readonly monitor: McpCatalogMonitor;
|
|
60
|
+
readonly limits?: Partial<McpToolLimits>;
|
|
61
|
+
readonly signal?: AbortSignal;
|
|
62
|
+
}
|
|
63
|
+
export declare function discoverMcpTools(client: Client, serverId: string, bindings: Readonly<Record<string, McpToolBinding>>, options: McpDiscoveryOptions): Promise<McpToolCatalog>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { CallToolResultSchema, ListToolsResultSchema, ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
import { AgentCanceledError, AgentError, StructuredOutputContract, StructuredOutputError, jsonIdentityDigest } from "purra";
|
|
4
|
+
export class McpAdapterError extends AgentError {
|
|
5
|
+
constructor(code) { super(code, "MCP tool boundary rejected the operation"); }
|
|
6
|
+
}
|
|
7
|
+
function fail(code) { throw new McpAdapterError(code); }
|
|
8
|
+
const encoder = new TextEncoder();
|
|
9
|
+
const size = (value) => encoder.encode(JSON.stringify(value)).length;
|
|
10
|
+
const defaults = Object.freeze({ maxPages: 32, maxTools: 256, maxCatalogBytes: 1_048_576,
|
|
11
|
+
maxSchemaBytes: 65_536, maxDescriptionBytes: 8_192, maxResultBytes: 65_536, maxContentBlocks: 128, timeoutMs: 30_000 });
|
|
12
|
+
function limitsOf(input = {}) {
|
|
13
|
+
if (Object.keys(input).some(key => !(key in defaults)))
|
|
14
|
+
fail("mcp_binding_invalid");
|
|
15
|
+
const limits = { ...defaults, ...input };
|
|
16
|
+
for (const key of Object.keys(defaults)) {
|
|
17
|
+
if (!Number.isSafeInteger(limits[key]) || limits[key] <= 0 || limits[key] > (key === "timeoutMs" ? 300_000 : defaults[key]))
|
|
18
|
+
fail("mcp_binding_invalid");
|
|
19
|
+
}
|
|
20
|
+
return Object.freeze(limits);
|
|
21
|
+
}
|
|
22
|
+
/** Feed negotiated protocol, list-change notifications and disconnects from the host. */
|
|
23
|
+
export class McpCatalogMonitor {
|
|
24
|
+
#revision = 0;
|
|
25
|
+
#closed = false;
|
|
26
|
+
#protocol;
|
|
27
|
+
acceptProtocolVersion(version) {
|
|
28
|
+
if (!["2025-06-18", "2025-11-25"].includes(version)) {
|
|
29
|
+
this.close();
|
|
30
|
+
fail("mcp_protocol_unsupported");
|
|
31
|
+
}
|
|
32
|
+
if (this.#protocol !== undefined && this.#protocol !== version) {
|
|
33
|
+
this.close();
|
|
34
|
+
fail("mcp_protocol_conflict");
|
|
35
|
+
}
|
|
36
|
+
this.#protocol = version;
|
|
37
|
+
}
|
|
38
|
+
onNotification(notification) {
|
|
39
|
+
if (notification.method === "notifications/tools/list_changed")
|
|
40
|
+
this.#revision++;
|
|
41
|
+
}
|
|
42
|
+
close() { this.#closed = true; }
|
|
43
|
+
get revision() { return this.#revision; }
|
|
44
|
+
get protocolVersion() { return this.#protocol; }
|
|
45
|
+
check(revision) {
|
|
46
|
+
if (this.#closed)
|
|
47
|
+
fail("mcp_connection_closed");
|
|
48
|
+
if (this.#protocol === undefined)
|
|
49
|
+
fail("mcp_not_initialized");
|
|
50
|
+
if (revision !== this.#revision)
|
|
51
|
+
fail("mcp_catalog_stale");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function stopped(signal) { if (signal?.aborted)
|
|
55
|
+
throw new AgentCanceledError(); }
|
|
56
|
+
async function request(operation, signal) {
|
|
57
|
+
stopped(signal);
|
|
58
|
+
try {
|
|
59
|
+
return await operation();
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
stopped(signal);
|
|
63
|
+
if (error instanceof McpError) {
|
|
64
|
+
if (error.code === ErrorCode.RequestTimeout)
|
|
65
|
+
fail("mcp_timeout");
|
|
66
|
+
if (error.code === ErrorCode.ConnectionClosed)
|
|
67
|
+
fail("mcp_transport_error");
|
|
68
|
+
fail("mcp_protocol_error");
|
|
69
|
+
}
|
|
70
|
+
if (error !== null && typeof error === "object" && "name" in error
|
|
71
|
+
&& (error.name === "ZodError" || error.name === "$ZodError"))
|
|
72
|
+
fail("mcp_result_invalid");
|
|
73
|
+
fail("mcp_transport_error");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function contract(schema, limits) {
|
|
77
|
+
try {
|
|
78
|
+
return await StructuredOutputContract.create({ schemaId: "mcp.object", schemaVersion: "1", schema,
|
|
79
|
+
limits: { schemaBytes: limits.maxSchemaBytes, outputBytes: limits.maxResultBytes } });
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return fail("mcp_schema_unsupported");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function remoteContract(schema, limits) {
|
|
86
|
+
// Keep the recognized MCP root dialect in snapshot identity, outside the constraint profile.
|
|
87
|
+
if (size(schema) > limits.maxSchemaBytes)
|
|
88
|
+
fail("mcp_schema_unsupported");
|
|
89
|
+
const declared = Object.hasOwn(schema, "$schema");
|
|
90
|
+
if (declared && schema.$schema !== "https://json-schema.org/draft/2020-12/schema")
|
|
91
|
+
fail("mcp_schema_unsupported");
|
|
92
|
+
const compiled = await contract(Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "$schema")), limits);
|
|
93
|
+
const snapshot = declared ? Object.freeze({ ...compiled.schema, $schema: schema.$schema }) : compiled.schema;
|
|
94
|
+
return { compiled, snapshot };
|
|
95
|
+
}
|
|
96
|
+
function resultValue(raw, output, envelope, limits) {
|
|
97
|
+
if (raw.isError)
|
|
98
|
+
fail("mcp_tool_error");
|
|
99
|
+
if (raw.content.length > limits.maxContentBlocks || size(raw) > limits.maxResultBytes)
|
|
100
|
+
fail("mcp_result_too_large");
|
|
101
|
+
const text = [];
|
|
102
|
+
for (const block of raw.content) {
|
|
103
|
+
if (block.type !== "text")
|
|
104
|
+
fail("mcp_result_unsupported");
|
|
105
|
+
text.push(block.text);
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const value = envelope.validateValue({ text, structured: raw.structuredContent ?? null });
|
|
109
|
+
output?.validateValue(raw.structuredContent ?? null);
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error instanceof StructuredOutputError)
|
|
114
|
+
fail("mcp_result_invalid");
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export async function discoverMcpTools(client, serverId, bindings, options) {
|
|
119
|
+
const monitor = options.monitor;
|
|
120
|
+
const limits = limitsOf(options.limits);
|
|
121
|
+
if (!(client instanceof Client) || !(monitor instanceof McpCatalogMonitor)
|
|
122
|
+
|| typeof serverId !== "string" || !/^[A-Za-z0-9_.-]{1,128}$/.test(serverId)
|
|
123
|
+
|| !bindings || typeof bindings !== "object" || Array.isArray(bindings))
|
|
124
|
+
fail("mcp_binding_invalid");
|
|
125
|
+
const bound = new Map();
|
|
126
|
+
const localNames = new Set();
|
|
127
|
+
for (const [remote, binding] of Object.entries(bindings)) {
|
|
128
|
+
if (!/^[A-Za-z0-9_.-]{1,128}$/.test(remote) || !binding || typeof binding.localName !== "string"
|
|
129
|
+
|| !/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(binding.localName) || binding.policy?.mode !== "read"
|
|
130
|
+
|| binding.policy.riskLevel !== undefined && binding.policy.riskLevel !== "read"
|
|
131
|
+
|| typeof binding.policy.title !== "string" || !binding.policy.title.trim()
|
|
132
|
+
|| binding.scope !== null && typeof binding.scope !== "function"
|
|
133
|
+
|| binding.concurrencySafe !== undefined && typeof binding.concurrencySafe !== "boolean")
|
|
134
|
+
fail("mcp_binding_invalid");
|
|
135
|
+
if (localNames.has(binding.localName))
|
|
136
|
+
fail("mcp_name_conflict");
|
|
137
|
+
localNames.add(binding.localName);
|
|
138
|
+
bound.set(remote, Object.freeze({ ...binding, policy: Object.freeze({ mode: "read", title: binding.policy.title.trim(), riskLevel: "read" }) }));
|
|
139
|
+
}
|
|
140
|
+
if (bound.size === 0 || bound.size > limits.maxTools)
|
|
141
|
+
fail("mcp_binding_invalid");
|
|
142
|
+
const revision = monitor.revision;
|
|
143
|
+
monitor.check(revision);
|
|
144
|
+
if (client.getServerCapabilities()?.tools === undefined || client.transport === undefined)
|
|
145
|
+
fail("mcp_tools_unavailable");
|
|
146
|
+
const envelope = await contract({ type: "object" }, limits);
|
|
147
|
+
const seen = new Set(), cursors = new Set();
|
|
148
|
+
const found = new Map();
|
|
149
|
+
let cursor, totalBytes = 0, finished = false;
|
|
150
|
+
const requestOptions = { timeout: limits.timeoutMs, maxTotalTimeout: limits.timeoutMs,
|
|
151
|
+
...(options.signal === undefined ? {} : { signal: options.signal }) };
|
|
152
|
+
for (let page = 0; page < limits.maxPages; page++) {
|
|
153
|
+
monitor.check(revision);
|
|
154
|
+
// Do not use callTool/listTools convenience caches or implicit rediscovery.
|
|
155
|
+
const result = await request(() => client.request({ method: "tools/list", ...(cursor === undefined ? {} : { params: { cursor } }) }, ListToolsResultSchema, requestOptions), options.signal);
|
|
156
|
+
monitor.check(revision);
|
|
157
|
+
totalBytes += size(result);
|
|
158
|
+
if (totalBytes > limits.maxCatalogBytes || seen.size + result.tools.length > limits.maxTools)
|
|
159
|
+
fail("mcp_catalog_limit_exceeded");
|
|
160
|
+
for (const tool of result.tools) {
|
|
161
|
+
if (seen.has(tool.name))
|
|
162
|
+
fail("mcp_name_conflict");
|
|
163
|
+
seen.add(tool.name);
|
|
164
|
+
const binding = bound.get(tool.name);
|
|
165
|
+
if (binding === undefined)
|
|
166
|
+
continue;
|
|
167
|
+
const description = tool.description ?? "";
|
|
168
|
+
if (encoder.encode(description).length > limits.maxDescriptionBytes)
|
|
169
|
+
fail("mcp_catalog_limit_exceeded");
|
|
170
|
+
if (tool.execution?.taskSupport === "required")
|
|
171
|
+
fail("mcp_tool_unsupported");
|
|
172
|
+
const inputContract = await remoteContract(tool.inputSchema, limits);
|
|
173
|
+
const outputContract = tool.outputSchema === undefined ? undefined : await remoteContract(tool.outputSchema, limits);
|
|
174
|
+
const input = inputContract.compiled, output = outputContract?.compiled;
|
|
175
|
+
const entry = Object.freeze({ remoteName: tool.name, localName: binding.localName, description,
|
|
176
|
+
inputSchema: inputContract.snapshot, outputSchema: outputContract?.snapshot ?? null,
|
|
177
|
+
policy: Object.freeze({ mode: "read", title: binding.policy.title, riskLevel: "read" }), concurrencySafe: binding.concurrencySafe ?? false });
|
|
178
|
+
found.set(tool.name, { entry, binding, input, output });
|
|
179
|
+
}
|
|
180
|
+
cursor = result.nextCursor;
|
|
181
|
+
if (cursor === undefined) {
|
|
182
|
+
finished = true;
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
if (!cursor || encoder.encode(cursor).length > 512 || cursors.has(cursor))
|
|
186
|
+
fail("mcp_pagination_invalid");
|
|
187
|
+
cursors.add(cursor);
|
|
188
|
+
}
|
|
189
|
+
if (!finished)
|
|
190
|
+
fail("mcp_catalog_limit_exceeded");
|
|
191
|
+
if (found.size !== bound.size)
|
|
192
|
+
fail("mcp_binding_missing");
|
|
193
|
+
const ordered = [...found.entries()].sort((a, b) => a[1].entry.localName < b[1].entry.localName ? -1 : 1);
|
|
194
|
+
const identity = Object.freeze({ schemaVersion: 1, serverId, protocolVersion: monitor.protocolVersion,
|
|
195
|
+
entries: Object.freeze(ordered.map(([, row]) => row.entry)) });
|
|
196
|
+
let revisionDigest;
|
|
197
|
+
try {
|
|
198
|
+
revisionDigest = await jsonIdentityDigest(identity);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return fail("mcp_catalog_limit_exceeded");
|
|
202
|
+
}
|
|
203
|
+
const snapshot = Object.freeze({ ...identity, revisionDigest });
|
|
204
|
+
const registrations = Object.freeze(ordered.map(([remote, row]) => {
|
|
205
|
+
const scope = async (input, context) => {
|
|
206
|
+
monitor.check(revision);
|
|
207
|
+
stopped(context.signal);
|
|
208
|
+
return row.binding.scope?.(input, context);
|
|
209
|
+
};
|
|
210
|
+
return Object.freeze({ name: row.entry.localName, description: row.entry.description || row.binding.policy.title,
|
|
211
|
+
inputSchema: row.input.schema, argumentContract: row.input, policy: row.binding.policy, concurrencySafe: row.entry.concurrencySafe,
|
|
212
|
+
scope, async run(input, context) {
|
|
213
|
+
try {
|
|
214
|
+
monitor.check(revision);
|
|
215
|
+
let value;
|
|
216
|
+
try {
|
|
217
|
+
value = row.input.validateValue(input);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return { content: { text: [], structured: null }, effectState: "not_started", errorCode: "mcp_invalid_arguments" };
|
|
221
|
+
}
|
|
222
|
+
const decision = await scope(value, context);
|
|
223
|
+
if (decision === false || typeof decision === "string" && decision.trim())
|
|
224
|
+
fail("mcp_scope_denied");
|
|
225
|
+
monitor.check(revision);
|
|
226
|
+
stopped(context.signal);
|
|
227
|
+
const result = await request(() => client.request({ method: "tools/call", params: { name: remote, arguments: value } }, CallToolResultSchema, { timeout: limits.timeoutMs, maxTotalTimeout: limits.timeoutMs, ...(context.signal === undefined ? {} : { signal: context.signal }) }), context.signal);
|
|
228
|
+
monitor.check(revision);
|
|
229
|
+
return { content: resultValue(result, row.output, envelope, limits), effectState: "not_started" };
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
if (error instanceof McpAdapterError)
|
|
233
|
+
return { content: { text: [], structured: null }, effectState: "not_started", errorCode: error.code };
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
}));
|
|
239
|
+
monitor.check(revision);
|
|
240
|
+
return Object.freeze({ snapshot, registrations, get stale() { try {
|
|
241
|
+
monitor.check(revision);
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return true;
|
|
246
|
+
} } });
|
|
247
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "purra-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "Host-owned, read-only MCP tools for PurrA",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"README.md",
|
|
10
|
+
"LICENSE"
|
|
11
|
+
],
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@modelcontextprotocol/sdk": "1.30.0"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"purra": "1.0.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"purra": "file:../../../typescript",
|
|
26
|
+
"typescript": "7.0.2"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc -p tsconfig.json",
|
|
30
|
+
"test": "node --test test/*.test.mjs",
|
|
31
|
+
"check": "npm run build && npm test && npm run test:types",
|
|
32
|
+
"prepack": "npm run build",
|
|
33
|
+
"test:types": "tsc -p test/tsconfig.json"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22"
|
|
37
|
+
}
|
|
38
|
+
}
|