@mapled/mcp 0.18.0 → 0.18.2
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 +3 -1
- package/dist/index.js +4 -23
- package/dist/tools.d.ts +24 -0
- package/dist/tools.js +102 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,6 +38,8 @@ claude mcp add mapled -e MAPLED_MCP_TOKEN=mcp_live_… -- npx -y @mapled/mcp
|
|
|
38
38
|
|
|
39
39
|
The token is scoped to one project. Set `MAPLED_API_URL` only if you're not on the default `https://api.mapled.io`.
|
|
40
40
|
|
|
41
|
+
Over either transport, a refusal reaches the agent as Mapled words it — a key that is taken, a plan still waiting for approval, the slug it sent that another record already has. An error whose text carries anything shaped like a Mapled key or token that the call didn't send itself reaches it as one constant sentence instead: no tool answer, its errors included, hands the agent a secret.
|
|
42
|
+
|
|
41
43
|
## Setting up a site
|
|
42
44
|
|
|
43
45
|
For the first integration, propose one plan and let the owner approve it on a trusted Mapled screen:
|
|
@@ -63,7 +65,7 @@ A rename or a conversion Mapled wouldn't take is refused at once with the reason
|
|
|
63
65
|
| `propose_setup_plan` / `get_setup_run` / `apply_setup_plan` / `report_setup` / `verify_setup` | One approved plan for the whole setup — every field type including relations (`$ref` handles between plan records, ids for existing collections) and groups — verified by Mapled (see above) |
|
|
64
66
|
| `get_schema` | Read the project's collections and fields |
|
|
65
67
|
| `create_collection` | Add a collection or single |
|
|
66
|
-
| `add_field` | Add a field (short_text, long_text, rich_text, slug, image, number, boolean, date, datetime, relation, enum, url, email, group, file, color, json — an object or list up to 32 KB, location — { lat, lng }, computed). Optional `validation` ({min, max, pattern}) and `defaultValue`; `relation` ({target, cardinality: one \| many, onDelete: restrict \| nullify}) is required for relation fields — values are record ids of the target collection, kept in the order given; `onDelete` says what a delete of a linked record does (restrict: it can't be deleted while linked, nullify: the links are cleared — the default is restrict for required fields and nullify otherwise); `options` (1–50 labels) is required for enum fields; `sensitive: true` keeps a field out of lists, history and delivery (a group's sub-fields take it too); `group` ({fields, repeatable, maxItems}) shapes a group field — its values are objects (or arrays of them) keyed by the sub-field keys; `computed` ({expression}) makes a computed field — a formula Mapled evaluates whenever a record is read or published, over the record's fields and up to two links through relations (`author.company.name`, `sum(items.product.price)`) or back along one and one link on (`count(@posts.author)`, `sum(@order-items.order.product.price)`), at most one list per path, never a sensitive field or relation; the site reads the value like any field of its result type. |
|
|
68
|
+
| `add_field` | Add a field (short_text, long_text, rich_text, slug, image, number, boolean, date, datetime, relation, enum, url, email, group, file, color, json — an object or list up to 32 KB, location — { lat, lng }, computed). Optional `validation` ({min, max, pattern}) and `defaultValue`; `relation` ({target, cardinality: one \| many, onDelete: restrict \| nullify}) is required for relation fields — values are record ids of the target collection, kept in the order given; `onDelete` says what a delete of a linked record does (restrict: it can't be deleted while linked, nullify: the links are cleared — the default is restrict for required fields and nullify otherwise); `options` (1–50 labels) is required for enum fields; `sensitive: true` keeps a field out of lists, history and delivery (a group's sub-fields take it too); `group` ({fields, repeatable, maxItems}) shapes a group field — its values are objects (or arrays of them) keyed by the sub-field keys; `computed` ({expression}) makes a computed field — a formula Mapled evaluates whenever a record is read or published, over the record's fields and up to two links through relations (`author.company.name`, `sum(items.product.price)`) or back along one and one link on (`count(@posts.author)`, `sum(@order-items.order.product.price)`), at most one list per path, never a sensitive field or relation; `today()` and `now()` are the moment the value was computed — baked at publish and recomputed once a day for the current release; the site reads the value like any field of its result type. |
|
|
67
69
|
| `add_records` | Insert draft records |
|
|
68
70
|
| `list_records` | Read a collection's draft records, newest edit first — `query` searches their content, `limit` (1–200) and `cursor` (the previous answer's `nextCursor`) page through them; `total` counts every match |
|
|
69
71
|
| `create_form` / `list_forms` | Set up public forms with spam protection |
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
-
import { createApiClient,
|
|
4
|
+
import { createApiClient, registerTools } from "./tools.js";
|
|
5
5
|
/** Mapled MCP server. Auth and scope come from the environment:
|
|
6
6
|
MAPLED_MCP_TOKEN — the project token from Connect with AI (required)
|
|
7
7
|
MAPLED_API_URL — API origin (default https://api.mapled.io) */
|
|
@@ -12,27 +12,8 @@ if (!token) {
|
|
|
12
12
|
}
|
|
13
13
|
const baseUrl = process.env.MAPLED_API_URL ?? "https://api.mapled.io";
|
|
14
14
|
const server = new McpServer({ name: "mapled", version: "0.1.0" });
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
// runtime contract is identical, so erase the generics here.
|
|
19
|
-
server.tool(tool.name, tool.description, tool.schema, async (args) => {
|
|
20
|
-
try {
|
|
21
|
-
const result = await tool.handler(args);
|
|
22
|
-
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
23
|
-
}
|
|
24
|
-
catch (err) {
|
|
25
|
-
return {
|
|
26
|
-
isError: true,
|
|
27
|
-
content: [
|
|
28
|
-
{
|
|
29
|
-
type: "text",
|
|
30
|
-
text: err instanceof Error ? err.message : "Mapled request failed.",
|
|
31
|
-
},
|
|
32
|
-
],
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
});
|
|
36
|
-
}
|
|
15
|
+
// every call answers through runTool: an error reaches the model only
|
|
16
|
+
// as a text without a Mapled key or token in it (§36.12)
|
|
17
|
+
registerTools(server, createApiClient(baseUrl, token));
|
|
37
18
|
const transport = new StdioServerTransport();
|
|
38
19
|
await server.connect(transport);
|
package/dist/tools.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { z } from "zod";
|
|
|
4
4
|
export type ApiClient = {
|
|
5
5
|
request: (method: "GET" | "POST" | "PATCH", path: string, body?: unknown) => Promise<unknown>;
|
|
6
6
|
};
|
|
7
|
+
/** What a call answers when its request never reached Mapled. */
|
|
8
|
+
export declare const UNREACHABLE = "The request didn't reach Mapled. Try again, or tell the person if it keeps failing: MAPLED_API_URL or MAPLED_MCP_TOKEN in the MCP settings may need a fresh copy. Don't ask them to paste the token into the conversation.";
|
|
7
9
|
export declare function createApiClient(baseUrl: string, token: string): ApiClient;
|
|
8
10
|
export declare const FIELD_TYPES: readonly ["short_text", "long_text", "rich_text", "slug", "image", "number", "boolean", "date", "relation", "enum", "url", "email", "group", "datetime", "file", "color", "json", "location", "computed"];
|
|
9
11
|
export type ToolDef = {
|
|
@@ -12,4 +14,26 @@ export type ToolDef = {
|
|
|
12
14
|
schema: z.ZodRawShape;
|
|
13
15
|
handler: (args: never) => Promise<unknown>;
|
|
14
16
|
};
|
|
17
|
+
/** What a failed call answers when its own text can't pass. */
|
|
18
|
+
export declare const HELD_BACK = "Mapled couldn't complete this call, and its error can't be shown here. Try again, or tell the person if it keeps failing.";
|
|
19
|
+
/** The text an error reaches the model with (§36.12): its message when
|
|
20
|
+
routeText passes it — a route's refusal, as worded — or HELD_BACK.
|
|
21
|
+
`args` are the call's arguments, the runs the model sent. Never
|
|
22
|
+
throws: what escapes a tool's callback the SDK answers with, unread. */
|
|
23
|
+
export declare function errorText(err: unknown, args?: unknown): string;
|
|
24
|
+
export type ToolAnswer = {
|
|
25
|
+
content: {
|
|
26
|
+
type: "text";
|
|
27
|
+
text: string;
|
|
28
|
+
}[];
|
|
29
|
+
isError?: true;
|
|
30
|
+
};
|
|
31
|
+
/** One call as the model reads it, over stdio and the hosted /mcp alike:
|
|
32
|
+
the tool's answer as JSON, or an error that errorText lets through. */
|
|
33
|
+
export declare function runTool(tool: ToolDef, args: unknown): Promise<ToolAnswer>;
|
|
34
|
+
/** The one way a transport offers the tools: every call answers through
|
|
35
|
+
runTool, so no error text reaches the model past it. */
|
|
36
|
+
export declare function registerTools(server: {
|
|
37
|
+
tool: (...args: never[]) => unknown;
|
|
38
|
+
}, api: ApiClient): void;
|
|
15
39
|
export declare function createTools(api: ApiClient): ToolDef[];
|
package/dist/tools.js
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
/** What a call answers when its request never reached Mapled. */
|
|
3
|
+
export const UNREACHABLE = "The request didn't reach Mapled. Try again, or tell the person if it keeps failing: MAPLED_API_URL or MAPLED_MCP_TOKEN in the MCP settings may need a fresh copy. Don't ask them to paste the token into the conversation.";
|
|
2
4
|
export function createApiClient(baseUrl, token) {
|
|
3
5
|
return {
|
|
4
6
|
async request(method, path, body) {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
let res;
|
|
8
|
+
try {
|
|
9
|
+
res = await fetch(`${baseUrl}${path}`, {
|
|
10
|
+
method,
|
|
11
|
+
headers: {
|
|
12
|
+
authorization: `Bearer ${token}`,
|
|
13
|
+
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
|
14
|
+
},
|
|
15
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// the runtime's text is no route's refusal, and it may quote the
|
|
20
|
+
// request: a header it refused names the token, in pieces too short
|
|
21
|
+
// for any text filter when line breaks cut it (§36.12)
|
|
22
|
+
throw new Error(UNREACHABLE);
|
|
23
|
+
}
|
|
13
24
|
const payload = (await res.json().catch(() => null));
|
|
14
25
|
if (!res.ok) {
|
|
15
26
|
throw new Error(payload?.error?.message ?? `Mapled API error (${res.status}).`);
|
|
@@ -44,22 +55,93 @@ const COMPUTED_HELP = "For type computed only: { expression } — a formula over
|
|
|
44
55
|
"Field keys as written (price, unit-cost — put spaces around a minus to subtract: price - cost); up to two links through relations: author.name, author.company.name, and lists over many-relations for aggregates: sum(items.price), count(tags), join(tags.name, \", \"), sum(items.product.price); or one link back — the records of another collection whose relation points at this record, written @collection.relation[.field], always a list, oldest first: count(@posts.author), sum(@order-items.order.total), max(@posts.author.published-on) — and one more link on from them: sum(@order-items.order.product.price). A path goes through at most one list (a many-relation, or the records that link back), so items.tags.name is refused. " +
|
|
45
56
|
"Numbers: + - * / %, round(x, digits), floor, ceil, abs, min, max, sum, avg, count, fixed(x, digits) → text. Text: & joins (empty counts as \"\"), concat, upper, lower, trim, length, left(s, n), right(s, n), replace(s, from, to), contains(s, part), slug(s), text(x), number(s). " +
|
|
46
57
|
"Logic: = != < <= > >=, and, or, not, if(cond, a, b), coalesce(a, b), empty(x). Dates: year, month, day, date(datetime), daysBetween(a, b), addDays(d, n), created() — when the record was added (a datetime). Literals: 12, 2.5, \"text\", true, false, null. " +
|
|
47
|
-
"
|
|
58
|
+
"today() and now() — the day and the moment the value was computed: a publish bakes them into the release, and Mapled recomputes the current release once a day (after midnight UTC), sending the publish webhook again — so daysBetween(today(), due) counts down, but not by the minute; a pinned release keeps its values. " +
|
|
59
|
+
"Sensitive fields and relations, groups, JSON and location can't be read. The result type (number, text, boolean, date, datetime) follows from the formula.";
|
|
48
60
|
/** The variable the site's revalidate route verifies publishes with. */
|
|
49
61
|
const WEBHOOK_SECRET_ENV = "MAPLED_WEBHOOK_SECRET";
|
|
50
62
|
/** The header a publish webhook carries its signature in. */
|
|
51
63
|
const SIGNATURE_HEADER = "x-mapled-signature";
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
64
|
+
/** The prefix of every key and token Mapled issues — the report mask's
|
|
65
|
+
`KEYS` (apps/api/src/lib/reportRedaction.ts); the API's
|
|
66
|
+
`security/mcpErrors.test.ts` sends one of each kind through both
|
|
67
|
+
transports. */
|
|
68
|
+
const CREDENTIAL_PREFIX = /(?:whsec|mk_live|msk_live|mcp_live|mcp_oauth|mcp_refresh|mcp_code|mpv|mps)_/i;
|
|
69
|
+
/** What a reader takes a text for: compatibility forms read as ASCII
|
|
70
|
+
(fullwidth letters, the fullwidth low line), and marks, control
|
|
71
|
+
characters other than line breaks and tabs, and characters drawn as
|
|
72
|
+
nothing are gone — a key interleaved with them reads whole. */
|
|
73
|
+
const INVISIBLE = /[\p{M}\p{Cf}\p{Default_Ignorable_Code_Point}\p{Cn}\p{Cs}\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/gu;
|
|
74
|
+
const readAs = (text) => text.normalize("NFKD").replace(INVISIBLE, "");
|
|
75
|
+
/** Runs of a text that read as a key's or token's random part, each with
|
|
76
|
+
what makes it one:
|
|
77
|
+
- 24 letters and digits — a hex key or secret is 32 or 48;
|
|
78
|
+
- 24 or more characters of base64 or base64url that mix upper- and
|
|
79
|
+
lower-case letters, as a random one does — a key, a slug or a path
|
|
80
|
+
Mapled writes is lower-case, a constant's name upper-case;
|
|
81
|
+
- as many characters of base64url as a token's random part has, 43
|
|
82
|
+
(`generateToken`: 32 bytes), whatever its letters — a random one
|
|
83
|
+
can come out all in one case or with no letter at all. */
|
|
84
|
+
const RANDOM_RUNS = [
|
|
85
|
+
[/[A-Za-z0-9]{24,}/g, () => true],
|
|
86
|
+
[/[A-Za-z0-9+/=_-]{24,}/g, (run) => /[A-Z]/.test(run) && /[a-z]/.test(run)],
|
|
87
|
+
[/[A-Za-z0-9_-]{43,}/g, () => true],
|
|
88
|
+
];
|
|
89
|
+
/** A text as the route words it, or nothing. It never passes when, read as
|
|
90
|
+
a reader would, it holds a Mapled key or token prefix in any letter
|
|
91
|
+
case or a run that reads as a random part (RANDOM_RUNS) the model
|
|
92
|
+
didn't send itself — or when it is longer than 1000 characters. A run
|
|
93
|
+
the model sent (`sent` — the call's arguments), echoed back, tells it
|
|
94
|
+
nothing: the slug it asked for that another record already has passes,
|
|
95
|
+
however long. So a credential whole, with its prefix or its random
|
|
96
|
+
part alone can't reach the model. A text filter can't promise more —
|
|
97
|
+
one split into short pieces or re-encoded passes — and the route's
|
|
56
98
|
refusals have to pass as worded. */
|
|
57
|
-
const routeText = (value
|
|
58
|
-
value.length
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
99
|
+
const routeText = (value, sent = "") => {
|
|
100
|
+
if (typeof value !== "string" || value.length > 1000)
|
|
101
|
+
return undefined;
|
|
102
|
+
const read = readAs(value);
|
|
103
|
+
if (CREDENTIAL_PREFIX.test(read))
|
|
104
|
+
return undefined;
|
|
105
|
+
const random = RANDOM_RUNS.flatMap(([runs, like]) => (read.match(runs) ?? []).filter((run) => like(run)));
|
|
106
|
+
if (random.length === 0)
|
|
107
|
+
return value;
|
|
108
|
+
const known = readAs(sent);
|
|
109
|
+
return random.every((run) => known.includes(run)) ? value : undefined;
|
|
110
|
+
};
|
|
111
|
+
/** What a failed call answers when its own text can't pass. */
|
|
112
|
+
export const HELD_BACK = "Mapled couldn't complete this call, and its error can't be shown here. Try again, or tell the person if it keeps failing.";
|
|
113
|
+
/** The text an error reaches the model with (§36.12): its message when
|
|
114
|
+
routeText passes it — a route's refusal, as worded — or HELD_BACK.
|
|
115
|
+
`args` are the call's arguments, the runs the model sent. Never
|
|
116
|
+
throws: what escapes a tool's callback the SDK answers with, unread. */
|
|
117
|
+
export function errorText(err, args) {
|
|
118
|
+
try {
|
|
119
|
+
return routeText(err instanceof Error ? err.message : undefined, JSON.stringify(args) ?? "") ?? HELD_BACK;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return HELD_BACK;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** One call as the model reads it, over stdio and the hosted /mcp alike:
|
|
126
|
+
the tool's answer as JSON, or an error that errorText lets through. */
|
|
127
|
+
export async function runTool(tool, args) {
|
|
128
|
+
try {
|
|
129
|
+
const result = await tool.handler(args);
|
|
130
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
return { isError: true, content: [{ type: "text", text: errorText(err, args) }] };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** The one way a transport offers the tools: every call answers through
|
|
137
|
+
runTool, so no error text reaches the model past it. */
|
|
138
|
+
export function registerTools(server, api) {
|
|
139
|
+
for (const tool of createTools(api)) {
|
|
140
|
+
// The SDK's generic inference recurses on our union of shapes; the
|
|
141
|
+
// runtime contract is identical, so erase the generics here.
|
|
142
|
+
server.tool(tool.name, tool.description, tool.schema, (args) => runTool(tool, args));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
63
145
|
/** The address the route saved: the agent's own, as the URL parser writes it. */
|
|
64
146
|
const savedUrl = (sent, answered) => {
|
|
65
147
|
try {
|
|
@@ -105,7 +187,7 @@ function configureRevalidation(api) {
|
|
|
105
187
|
hook = ((await api.request("PATCH", "/v1/agent/webhook", { url: args.url })) ?? {});
|
|
106
188
|
}
|
|
107
189
|
catch (err) {
|
|
108
|
-
throw new Error(routeText(err instanceof Error ? err.message : undefined) ?? "Mapled couldn't save the webhook. Try again.");
|
|
190
|
+
throw new Error(routeText(err instanceof Error ? err.message : undefined, JSON.stringify(args)) ?? "Mapled couldn't save the webhook. Try again.");
|
|
109
191
|
}
|
|
110
192
|
const status = hook.signingSecret?.status;
|
|
111
193
|
return {
|