@bevel-software/platform-mcp-core 0.8.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 +202 -0
- package/THIRD-PARTY-NOTICES.md +3065 -0
- package/dist/code-mode-names.d.ts +40 -0
- package/dist/code-mode-names.d.ts.map +1 -0
- package/dist/code-mode-names.js +93 -0
- package/dist/code-mode-names.js.map +1 -0
- package/dist/dispatch.d.ts +44 -0
- package/dist/dispatch.d.ts.map +1 -0
- package/dist/dispatch.js +72 -0
- package/dist/dispatch.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/meta-tools.d.ts +27 -0
- package/dist/meta-tools.d.ts.map +1 -0
- package/dist/meta-tools.js +161 -0
- package/dist/meta-tools.js.map +1 -0
- package/dist/proxied-tool.d.ts +49 -0
- package/dist/proxied-tool.d.ts.map +1 -0
- package/dist/proxied-tool.js +183 -0
- package/dist/proxied-tool.js.map +1 -0
- package/dist/results.d.ts +19 -0
- package/dist/results.d.ts.map +1 -0
- package/dist/results.js +130 -0
- package/dist/results.js.map +1 -0
- package/dist/skills.d.ts +22 -0
- package/dist/skills.d.ts.map +1 -0
- package/dist/skills.js +20 -0
- package/dist/skills.js.map +1 -0
- package/dist/utcp-namespace.d.ts +30 -0
- package/dist/utcp-namespace.d.ts.map +1 -0
- package/dist/utcp-namespace.js +66 -0
- package/dist/utcp-namespace.js.map +1 -0
- package/package.json +50 -0
- package/src/code-mode-names.ts +107 -0
- package/src/dispatch.ts +88 -0
- package/src/index.ts +71 -0
- package/src/meta-tools.ts +186 -0
- package/src/proxied-tool.ts +200 -0
- package/src/results.ts +131 -0
- package/src/skills.ts +34 -0
- package/src/utcp-namespace.ts +70 -0
package/src/results.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extract a human-meaningful failure message from a tool-call error. UTCP's
|
|
5
|
+
* HTTP protocol surfaces a non-2xx as an axios-style error whose `.response.data`
|
|
6
|
+
* is the REST endpoint's JSON body (`{ error: "..." }`). Pull that out so the
|
|
7
|
+
* MCP caller sees the tool's real message instead of a bare "status code 500".
|
|
8
|
+
*/
|
|
9
|
+
export function describeToolFailure(err: unknown): string {
|
|
10
|
+
const data = (err as { response?: { data?: unknown } })?.response?.data;
|
|
11
|
+
if (data && typeof data === 'object') {
|
|
12
|
+
const inner = (data as { error?: unknown }).error;
|
|
13
|
+
if (typeof inner === 'string' && inner.length > 0) return inner;
|
|
14
|
+
}
|
|
15
|
+
if (typeof data === 'string' && data.length > 0) return data;
|
|
16
|
+
// Total, like `safeJsonText`: a thrown value whose own `toString` throws
|
|
17
|
+
// (e.g. a null-prototype object) must still come back as a description —
|
|
18
|
+
// this function runs inside catch paths, where a second throw would turn
|
|
19
|
+
// a tool failure into a handler failure.
|
|
20
|
+
try {
|
|
21
|
+
return err instanceof Error ? err.message : String(err);
|
|
22
|
+
} catch {
|
|
23
|
+
return '(indescribable tool failure)';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Turn a tool's final value into an MCP result:
|
|
29
|
+
* - a tool that already returns the MCP agentic shape (`{ content: [...] }`,
|
|
30
|
+
* each entry a real content block) is passed through unchanged;
|
|
31
|
+
* - a bare string becomes the text content;
|
|
32
|
+
* - anything else is JSON-stringified into one text block.
|
|
33
|
+
*
|
|
34
|
+
* Note we do NOT collapse a structured object down to its `text` field: doing
|
|
35
|
+
* so silently dropped the other fields (e.g. `ask`'s `status` / `sessionId`,
|
|
36
|
+
* the id a caller must echo back to poll or continue a conversation).
|
|
37
|
+
* Stringifying the whole object keeps every field, so the caller always sees
|
|
38
|
+
* the id it is responsible for echoing.
|
|
39
|
+
*
|
|
40
|
+
* The passthrough guard checks the entries, not just that `content` is an array:
|
|
41
|
+
* a domain value that merely happens to carry a `content` array of non-blocks
|
|
42
|
+
* (e.g. `{ content: ['a', 'b'] }`) is data, not an MCP result, so it falls
|
|
43
|
+
* through to JSON-stringify and survives intact instead of being emitted as a
|
|
44
|
+
* malformed result the client can't parse.
|
|
45
|
+
*/
|
|
46
|
+
function isMcpContentBlock(entry: unknown): boolean {
|
|
47
|
+
return typeof entry === 'object' && entry !== null && typeof (entry as { type?: unknown }).type === 'string';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `JSON.stringify`, made total. A tool can legally hand back a value JSON
|
|
52
|
+
* refuses verbatim — a BigInt or circular object (throws), a bare
|
|
53
|
+
* function/symbol (stringifies to `undefined`) — and a COMPLETED call must not
|
|
54
|
+
* turn into a crash at the serialization step. The plain stringify runs first
|
|
55
|
+
* so well-formed values (shared non-circular references included) come out
|
|
56
|
+
* exactly as before; only a value it rejects degrades to the replacer pass
|
|
57
|
+
* (BigInt → decimal string, its own ancestor → '[Circular]'), and a value even
|
|
58
|
+
* that can't take (e.g. a throwing `toJSON`) falls back to `String(value)`.
|
|
59
|
+
*/
|
|
60
|
+
function safeJsonText(value: unknown): string {
|
|
61
|
+
try {
|
|
62
|
+
const text = JSON.stringify(value);
|
|
63
|
+
if (text !== undefined) return text;
|
|
64
|
+
} catch {
|
|
65
|
+
// BigInt / circular / throwing toJSON — degrade below.
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
// Circularity is being one's own ANCESTOR, not being visited twice: a
|
|
69
|
+
// shared (diamond) reference is legal JSON and stringify visits it once
|
|
70
|
+
// per parent, so a grown-only "seen" set would mislabel every sibling
|
|
71
|
+
// share as circular. The stack tracks only the active descent — the
|
|
72
|
+
// holder (`this`) of the current key is necessarily the innermost live
|
|
73
|
+
// ancestor, so popping down to it discards branches already unwound.
|
|
74
|
+
const ancestors: object[] = [];
|
|
75
|
+
const text = JSON.stringify(value, function (this: unknown, _key, v: unknown) {
|
|
76
|
+
if (typeof v === 'bigint') return v.toString();
|
|
77
|
+
if (v && typeof v === 'object') {
|
|
78
|
+
while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) ancestors.pop();
|
|
79
|
+
if (ancestors.includes(v)) return '[Circular]';
|
|
80
|
+
ancestors.push(v);
|
|
81
|
+
}
|
|
82
|
+
return v;
|
|
83
|
+
});
|
|
84
|
+
if (text !== undefined) return text;
|
|
85
|
+
} catch {
|
|
86
|
+
// fall through to the value's own toString
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
return String(value);
|
|
90
|
+
} catch {
|
|
91
|
+
return '(unserializable tool output)';
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function toCallToolResult(value: unknown): CallToolResult {
|
|
96
|
+
// Already in MCP agentic format — pass through untouched, but only when every
|
|
97
|
+
// `content` entry is a real content block (has a string `type`).
|
|
98
|
+
const content = (value as { content?: unknown })?.content;
|
|
99
|
+
if (value && typeof value === 'object' && Array.isArray(content) && content.every(isMcpContentBlock)) {
|
|
100
|
+
return value as CallToolResult;
|
|
101
|
+
}
|
|
102
|
+
const text = typeof value === 'string' ? value : safeJsonText(value ?? null);
|
|
103
|
+
return { content: [{ type: 'text', text: text || '(tool produced no output)' }] };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function renderProgress(chunk: unknown): string {
|
|
107
|
+
const s = typeof chunk === 'string' ? chunk : safeJsonText(chunk);
|
|
108
|
+
return s.length > 500 ? s.slice(0, 497) + '...' : s;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function toolError(message: string): CallToolResult {
|
|
112
|
+
return { isError: true, content: [{ type: 'text', text: message }] };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The result returned when the caller is missing personal credentials a tool
|
|
117
|
+
* needs. Marked `isError` so the external agent surfaces it to the person rather
|
|
118
|
+
* than treating it as tool output. Names the tool, lists the missing items, and
|
|
119
|
+
* gives the absolute setup-page URL so the person can provide them and retry.
|
|
120
|
+
*/
|
|
121
|
+
export function needsAuthorizationResult(
|
|
122
|
+
toolName: string,
|
|
123
|
+
missing: string[],
|
|
124
|
+
connectUrl: string,
|
|
125
|
+
): CallToolResult {
|
|
126
|
+
const items = missing.join(', ');
|
|
127
|
+
const text =
|
|
128
|
+
`The "${toolName}" tool needs credentials you haven't set up yet: ${items}. ` +
|
|
129
|
+
`Open ${connectUrl} to connect your accounts and enter your keys, then run the tool again.`;
|
|
130
|
+
return { isError: true, content: [{ type: 'text', text }] };
|
|
131
|
+
}
|
package/src/skills.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills exposed as MCP prompts (slash commands in an MCP client).
|
|
3
|
+
*
|
|
4
|
+
* Both surfaces do this the same way and must keep doing it the same way: a
|
|
5
|
+
* skill's instructions are the prompt body, and the bundled files are named at
|
|
6
|
+
* the end with the exact call that fetches them. A person running the same
|
|
7
|
+
* skill through the hosted endpoint and through the local server should get
|
|
8
|
+
* identical text, so the formatting lives here rather than in either server.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Minimal skill shapes the prompt bridge needs (no dependency on `modules/skills`). */
|
|
12
|
+
export interface SkillSummary {
|
|
13
|
+
name: string;
|
|
14
|
+
description: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LoadedSkill extends SkillSummary {
|
|
18
|
+
body: string;
|
|
19
|
+
path: string;
|
|
20
|
+
files: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The prompt message text for a loaded skill: its instructions body + a pointer to bundled files. */
|
|
24
|
+
export function skillPromptText(skill: LoadedSkill): string {
|
|
25
|
+
const rel = skill.files.map((f) =>
|
|
26
|
+
f.startsWith(`${skill.path}/`) ? f.slice(skill.path.length + 1) : f,
|
|
27
|
+
);
|
|
28
|
+
// Each name is quoted: a comma inside a file name must not read as a list
|
|
29
|
+
// separator, and the quoted form is exactly the `file` value get_skill takes.
|
|
30
|
+
const footer = rel.length
|
|
31
|
+
? `\n\n---\nSkill folder: ${skill.path}\nBundled files (fetch each with the get_skill tool: { name: ${JSON.stringify(skill.name)}, file }): ${rel.map((f) => JSON.stringify(f)).join(', ')}`
|
|
32
|
+
: '';
|
|
33
|
+
return `${skill.body}${footer}`;
|
|
34
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The key UTCP uses to look up a manual's `${VAR}`. `@utcp/sdk` first SANITIZES
|
|
3
|
+
* the manual name — every non-word char (`-`, `.`, spaces, …) becomes `_`
|
|
4
|
+
* (`manualCallTemplate.name.replace(/[^\w]/g, "_")` at registration) — and then
|
|
5
|
+
* namespaces variables under `<namespace>_<VAR>` where every `_` in the
|
|
6
|
+
* sanitized namespace is DOUBLED (the variable substitutor), so `my_tool` + `KEY`
|
|
7
|
+
* → `my__tool_KEY` and `my-tool` + `KEY` → the very same `my__tool_KEY`. Our
|
|
8
|
+
* secret storage AND scope resolution must derive the exact same key. For an
|
|
9
|
+
* alphanumeric name (no `_`, no punctuation) this reduces to `<name>_<VAR>`,
|
|
10
|
+
* so plain tool names are unaffected.
|
|
11
|
+
*/
|
|
12
|
+
export function utcpNamespacePrefix(manualName: string): string {
|
|
13
|
+
return `${manualName.replace(/[^\w]/g, '_').replace(/_/g, '__')}_`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function utcpNamespacedKey(manualName: string, varName: string): string {
|
|
17
|
+
return `${utcpNamespacePrefix(manualName)}${varName}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The origin template WE mint for every Bevel-hosted manual's discovery URL
|
|
22
|
+
* (`${API_URL}/api/…`). `${API_URL}` expands to the loopback origin, so a
|
|
23
|
+
* Bevel-hosted URL always has this exact string as its scheme+authority.
|
|
24
|
+
*/
|
|
25
|
+
const LOOPBACK_ORIGIN_TEMPLATE = '${API_URL}';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* True only when `${API_URL}` is the URL's ORIGIN (scheme+authority) — the shape
|
|
29
|
+
* we ourselves produce for a Bevel-hosted manual. A third-party `.tool` author
|
|
30
|
+
* can embed the literal `${API_URL}` anywhere in a path or query
|
|
31
|
+
* (`http://evil.example/?x=${API_URL}`) but can NOT make it the authority without
|
|
32
|
+
* pointing the request back at our own loopback, so anchoring the trust decision
|
|
33
|
+
* to the origin is what a user `.tool`'s URL text cannot forge.
|
|
34
|
+
*/
|
|
35
|
+
function isBevelHostedUrl(url: string): boolean {
|
|
36
|
+
if (!url.startsWith(LOOPBACK_ORIGIN_TEMPLATE)) return false;
|
|
37
|
+
// The char right after the origin must delimit the authority; anything else
|
|
38
|
+
// (`${API_URL}.evil.com`, `${API_URL}evil`) is a different, untrusted host.
|
|
39
|
+
const next = url.charAt(LOOPBACK_ORIGIN_TEMPLATE.length);
|
|
40
|
+
return next === '' || next === '/' || next === '?' || next === '#';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Seed the loopback discovery vars (`<ns>_API_URL` + `<ns>_CONNECTION_KEY`) for
|
|
45
|
+
* every Bevel-HOSTED manual in `manuals` — one whose discovery `url` targets our
|
|
46
|
+
* own backend with `${API_URL}` as its ORIGIN (the KB manual and inline `.tool`
|
|
47
|
+
* sub-manuals). A third-party http/mcp `.tool` points at an arbitrary URL, so it
|
|
48
|
+
* is deliberately NOT seeded: handing it `connectionKey` would leak the bearer to
|
|
49
|
+
* that endpoint. Keying off the origin (not a bare `includes('${API_URL}')`) is
|
|
50
|
+
* deliberate — an `http` `.tool`'s URL is author-controlled, so a substring match
|
|
51
|
+
* would let `http://attacker/?x=${API_URL}` be seeded the bearer and exfiltrate
|
|
52
|
+
* it. This is the exfiltration-safe seeding rule shared by the external MCP proxy
|
|
53
|
+
* and the in-process agent factory, so the bearer-leak decision is written ONCE.
|
|
54
|
+
*/
|
|
55
|
+
export function seedBevelHostedManualVars(
|
|
56
|
+
manuals: readonly { name?: unknown; url?: unknown }[],
|
|
57
|
+
loopbackBaseUrl: string,
|
|
58
|
+
connectionKey: string,
|
|
59
|
+
): Record<string, string> {
|
|
60
|
+
const variables: Record<string, string> = {};
|
|
61
|
+
for (const m of manuals) {
|
|
62
|
+
const name = typeof m.name === 'string' ? m.name : '';
|
|
63
|
+
if (!name) continue;
|
|
64
|
+
const url = (m as { url?: unknown }).url;
|
|
65
|
+
if (typeof url !== 'string' || !isBevelHostedUrl(url)) continue;
|
|
66
|
+
variables[utcpNamespacedKey(name, 'API_URL')] = loopbackBaseUrl;
|
|
67
|
+
variables[utcpNamespacedKey(name, 'CONNECTION_KEY')] = connectionKey;
|
|
68
|
+
}
|
|
69
|
+
return variables;
|
|
70
|
+
}
|