@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.
Files changed (43) hide show
  1. package/LICENSE +202 -0
  2. package/THIRD-PARTY-NOTICES.md +3065 -0
  3. package/dist/code-mode-names.d.ts +40 -0
  4. package/dist/code-mode-names.d.ts.map +1 -0
  5. package/dist/code-mode-names.js +93 -0
  6. package/dist/code-mode-names.js.map +1 -0
  7. package/dist/dispatch.d.ts +44 -0
  8. package/dist/dispatch.d.ts.map +1 -0
  9. package/dist/dispatch.js +72 -0
  10. package/dist/dispatch.js.map +1 -0
  11. package/dist/index.d.ts +33 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +33 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/meta-tools.d.ts +27 -0
  16. package/dist/meta-tools.d.ts.map +1 -0
  17. package/dist/meta-tools.js +161 -0
  18. package/dist/meta-tools.js.map +1 -0
  19. package/dist/proxied-tool.d.ts +49 -0
  20. package/dist/proxied-tool.d.ts.map +1 -0
  21. package/dist/proxied-tool.js +183 -0
  22. package/dist/proxied-tool.js.map +1 -0
  23. package/dist/results.d.ts +19 -0
  24. package/dist/results.d.ts.map +1 -0
  25. package/dist/results.js +130 -0
  26. package/dist/results.js.map +1 -0
  27. package/dist/skills.d.ts +22 -0
  28. package/dist/skills.d.ts.map +1 -0
  29. package/dist/skills.js +20 -0
  30. package/dist/skills.js.map +1 -0
  31. package/dist/utcp-namespace.d.ts +30 -0
  32. package/dist/utcp-namespace.d.ts.map +1 -0
  33. package/dist/utcp-namespace.js +66 -0
  34. package/dist/utcp-namespace.js.map +1 -0
  35. package/package.json +50 -0
  36. package/src/code-mode-names.ts +107 -0
  37. package/src/dispatch.ts +88 -0
  38. package/src/index.ts +71 -0
  39. package/src/meta-tools.ts +186 -0
  40. package/src/proxied-tool.ts +200 -0
  41. package/src/results.ts +131 -0
  42. package/src/skills.ts +34 -0
  43. 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
+ }