create-futurity-plugin 1.0.0 → 1.1.1

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/index.ts CHANGED
@@ -1,13 +1,22 @@
1
1
  #!/usr/bin/env bun
2
- import { existsSync, mkdirSync, cpSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
3
- import { resolve, basename } from "node:path";
4
2
  import { execSync } from "node:child_process";
3
+ import {
4
+ cpSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ renameSync,
10
+ statSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { basename, join, resolve } from "node:path";
5
14
 
6
15
  const arg = process.argv[2];
7
16
 
8
17
  if (!arg) {
9
- console.error("Usage: bun create futurity-plugin <project-name>");
10
- console.error(" bun create futurity-plugin . (scaffold in current directory)");
18
+ console.error("Usage: bunx create-futurity-plugin@latest <project-name>");
19
+ console.error(" bunx create-futurity-plugin@latest . (scaffold in current directory)");
11
20
  process.exit(1);
12
21
  }
13
22
 
@@ -15,6 +24,13 @@ const isCwd = arg === ".";
15
24
  const targetDir = isCwd ? process.cwd() : resolve(process.cwd(), arg);
16
25
  const projectName = isCwd ? basename(process.cwd()) : arg;
17
26
 
27
+ /** A plugin id is a URL-safe slug, so it is derived rather than asked for. */
28
+ const slug =
29
+ projectName
30
+ .toLowerCase()
31
+ .replace(/[^a-z0-9]+/g, "-")
32
+ .replace(/^-+|-+$/g, "") || "my-plugin";
33
+
18
34
  if (!isCwd) {
19
35
  if (existsSync(targetDir)) {
20
36
  console.error(`Error: Directory "${arg}" already exists.`);
@@ -23,26 +39,51 @@ if (!isCwd) {
23
39
  mkdirSync(targetDir, { recursive: true });
24
40
  }
25
41
 
26
- // Copy template files
27
42
  const templateDir = resolve(import.meta.dirname, "template");
28
43
  cpSync(templateDir, targetDir, { recursive: true });
29
44
 
30
- // Process package.json template
31
- const pkgPath = resolve(targetDir, "package.json.tmpl");
32
- const pkgContent = readFileSync(pkgPath, "utf-8");
33
- writeFileSync(
34
- resolve(targetDir, "package.json"),
35
- pkgContent.replace(/\{\{name\}\}/g, projectName)
36
- );
37
- unlinkSync(pkgPath);
45
+ // npm strips a .gitignore from a published tarball and never unpacks a .env
46
+ // file, so both ship under a plain name and are restored here.
47
+ const renames: [from: string, to: string][] = [
48
+ ["package.json.tmpl", "package.json"],
49
+ ["_gitignore", ".gitignore"],
50
+ ["env.example", ".env.example"],
51
+ ];
52
+ for (const [from, to] of renames) {
53
+ const source = join(targetDir, from);
54
+ if (existsSync(source)) renameSync(source, join(targetDir, to));
55
+ }
56
+
57
+ const TEXT_FILE = /\.(ts|json|md|example)$|^\.(gitignore|env\.example)$/;
58
+
59
+ function fillPlaceholders(dir: string) {
60
+ for (const entry of readdirSync(dir)) {
61
+ if (entry === "node_modules") continue;
62
+ const path = join(dir, entry);
63
+ if (statSync(path).isDirectory()) {
64
+ fillPlaceholders(path);
65
+ continue;
66
+ }
67
+ if (!TEXT_FILE.test(entry)) continue;
68
+
69
+ const content = readFileSync(path, "utf-8");
70
+ if (!content.includes("{{")) continue;
71
+ writeFileSync(
72
+ path,
73
+ content.replace(/\{\{name\}\}/g, projectName).replace(/\{\{slug\}\}/g, slug),
74
+ );
75
+ }
76
+ }
77
+ fillPlaceholders(targetDir);
38
78
 
39
- // Install dependencies
40
79
  console.log(`\nScaffolding ${projectName}...\n`);
41
80
  execSync("bun install", { cwd: targetDir, stdio: "inherit" });
42
81
 
43
82
  console.log(`\n✅ Created ${projectName}\n`);
44
83
  console.log("Next steps:\n");
45
- if (!isCwd) {
46
- console.log(` cd ${arg}`);
47
- }
48
- console.log(" bun run src/index.ts\n");
84
+ if (!isCwd) console.log(` cd ${arg}`);
85
+ console.log(" cp .env.example .env");
86
+ console.log(" bun run keygen # put the private key in .env");
87
+ console.log(" bun run dev # serves on :3000\n");
88
+ console.log("Then write your tools in src/index.ts, and exchange your own");
89
+ console.log("credentials in the token endpoint in src/auth.ts.\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-futurity-plugin",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "type": "module",
5
5
  "description": "Scaffold a new Futurity MCP plugin",
6
6
  "author": "Futurity",
@@ -11,7 +11,10 @@
11
11
  "scripts": {
12
12
  "test": "echo 'No tests'"
13
13
  },
14
- "files": ["index.ts", "template"],
14
+ "files": [
15
+ "index.ts",
16
+ "template"
17
+ ],
15
18
  "keywords": [
16
19
  "create",
17
20
  "scaffold",
@@ -0,0 +1,51 @@
1
+ # {{name}}
2
+
3
+ A Futurity MCP plugin. It serves tools over MCP, and a signed manifest that
4
+ Futurity checks before it trusts them.
5
+
6
+ ## Run it
7
+
8
+ ```bash
9
+ cp .env.example .env
10
+ bun run keygen # writes a keypair; put the private half in .env
11
+ bun run dev # http://localhost:3000/mcp
12
+ ```
13
+
14
+ With `FUTURITY_SIGNING_KEY` set, the manifest is served at
15
+ `/.well-known/futurity/plugin` with its detached signature in the
16
+ `X-Futurity-Signature` header:
17
+
18
+ ```bash
19
+ curl -i http://localhost:3000/.well-known/futurity/plugin
20
+ ```
21
+
22
+ ## Register it
23
+
24
+ 1. Put the plugin on a public HTTPS address and set `PUBLIC_URL` to match.
25
+ While you develop: `cloudflared tunnel --url http://localhost:3000`.
26
+ 2. In Corint, open **Integrations → Developer Tools → Add**, paste the base
27
+ URL (not the `/mcp` path), and click **Discover**.
28
+ 3. Paste the public key from `bun run keygen` and click **Register Plugin**.
29
+ The signature is verified at that moment.
30
+
31
+ Registration needs plugin developer access on your account. Futurity
32
+ re-verifies the manifest every 24 hours, so keep the address stable.
33
+
34
+ ## Connect it
35
+
36
+ This plugin declares the `client_credentials` grant, so one set of credentials
37
+ belongs to the whole organization and an administrator saves them once. They
38
+ land at your `/oauth/token` endpoint in `src/auth.ts`; whatever `access_token`
39
+ you return there arrives on every tool call as `X-Plugin-Access-Token`, and
40
+ `requireAccessToken()` hands it to your handler.
41
+
42
+ Other grants — per-person OAuth, SAML bearer, an API key, or your own auth
43
+ flow — are in the plugin authentication reference.
44
+
45
+ ## Where to edit
46
+
47
+ | File | What lives there |
48
+ | --- | --- |
49
+ | `src/index.ts` | The manifest, the middleware wiring, and your tools |
50
+ | `src/auth.ts` | The token endpoint and the per-request credentials |
51
+ | `scripts/keygen.ts` | The Ed25519 keypair generator |
@@ -0,0 +1,7 @@
1
+ node_modules/
2
+ dist/
3
+ *.log
4
+ .env
5
+ .env.*
6
+ !.env.example
7
+ .DS_Store
@@ -0,0 +1,10 @@
1
+ # Ed25519 private key that signs the plugin manifest. Generate with `bun run keygen`.
2
+ # Without it the server still runs, but it serves no manifest and cannot be registered.
3
+ FUTURITY_SIGNING_KEY=
4
+
5
+ # The address Futurity reaches this plugin on. Must be public HTTPS before you
6
+ # register — a quick tunnel works while you develop:
7
+ # cloudflared tunnel --url http://localhost:3000
8
+ PUBLIC_URL=http://localhost:3000
9
+
10
+ PORT=3000
@@ -1,10 +1,11 @@
1
1
  {
2
- "name": "{{name}}",
2
+ "name": "{{slug}}",
3
3
  "version": "0.1.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "bun run --watch src/index.ts",
7
- "start": "bun run src/index.ts"
7
+ "start": "bun run src/index.ts",
8
+ "keygen": "bun run scripts/keygen.ts"
8
9
  },
9
10
  "dependencies": {
10
11
  "@futurity/plugins": "^2.0.0"
@@ -0,0 +1,8 @@
1
+ import { generateKeyPair } from "@futurity/plugins/signing";
2
+
3
+ const { privateKey, publicKey } = generateKeyPair();
4
+
5
+ console.log("\nPrivate key (PKCS8 DER, base64) — keep secret, put it in .env:\n");
6
+ console.log(`FUTURITY_SIGNING_KEY=${privateKey}\n`);
7
+ console.log("Public key (SPKI DER, base64) — paste this into Futurity when you register:\n");
8
+ console.log(`${publicKey}\n`);
@@ -0,0 +1,91 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import type { Middleware } from "@futurity/plugins";
3
+
4
+ /**
5
+ * Futurity sends two credentials on every tool call. `Authorization` carries
6
+ * Futurity's own short-lived token, which identifies the platform. The token
7
+ * your API cares about is in `X-Plugin-Access-Token`, and any extra fields the
8
+ * administrator filled in ride along as JSON in `X-Futurity-Data-Params`.
9
+ */
10
+ export type PluginRequest = {
11
+ accessToken: string | null;
12
+ dataParams: Record<string, string>;
13
+ };
14
+
15
+ const store = new AsyncLocalStorage<PluginRequest>();
16
+
17
+ /**
18
+ * The credentials for the call being served. Tool handlers receive their input
19
+ * only, so the request travels here instead of through an argument.
20
+ */
21
+ export function currentRequest(): PluginRequest {
22
+ return store.getStore() ?? { accessToken: null, dataParams: {} };
23
+ }
24
+
25
+ /** The forwarded token, or a thrown error when the caller sent none. */
26
+ export function requireAccessToken(): string {
27
+ const { accessToken } = currentRequest();
28
+ if (!accessToken) {
29
+ throw new Error(
30
+ "No X-Plugin-Access-Token on this request. Connect the integration in Futurity first.",
31
+ );
32
+ }
33
+ return accessToken;
34
+ }
35
+
36
+ function parseDataParams(raw: string | null): Record<string, string> {
37
+ if (!raw) return {};
38
+ try {
39
+ const parsed: unknown = JSON.parse(raw);
40
+ if (!parsed || typeof parsed !== "object") return {};
41
+ return Object.fromEntries(
42
+ Object.entries(parsed as Record<string, unknown>).filter(
43
+ (entry): entry is [string, string] => typeof entry[1] === "string",
44
+ ),
45
+ );
46
+ } catch {
47
+ return {};
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Bind each request's credentials for the duration of that request. An async
53
+ * store rather than a module variable, so overlapping calls cannot read each
54
+ * other's token.
55
+ */
56
+ export const forwardedAuth: Middleware = (req, next) =>
57
+ store.run(
58
+ {
59
+ accessToken: req.headers.get("X-Plugin-Access-Token"),
60
+ dataParams: parseDataParams(req.headers.get("X-Futurity-Data-Params")),
61
+ },
62
+ () => next(req),
63
+ );
64
+
65
+ /**
66
+ * The `client_credentials` token endpoint named by the manifest. Futurity posts
67
+ * the credentials an administrator saved, caches what you return until shortly
68
+ * before `expires_in`, and forwards `access_token` on every tool call.
69
+ */
70
+ export const tokenEndpoint = (path: string): Middleware => {
71
+ return async (req, next) => {
72
+ if (new URL(req.url).pathname !== path) return next(req);
73
+ if (req.method !== "POST") return next(req);
74
+
75
+ const form = await req.formData();
76
+ const clientId = String(form.get("client_id") ?? "");
77
+ const clientSecret = String(form.get("client_secret") ?? "");
78
+
79
+ if (!clientId || !clientSecret) {
80
+ return Response.json({ error: "invalid_client" }, { status: 401 });
81
+ }
82
+
83
+ // YOUR CODE HERE: exchange these credentials with the system you wrap, and
84
+ // return its token. The stub below lets the whole flow run end to end first.
85
+ return Response.json({
86
+ access_token: `${clientId}-demo-token`,
87
+ token_type: "Bearer",
88
+ expires_in: 3600,
89
+ });
90
+ };
91
+ };
@@ -1,16 +1,68 @@
1
- import { mcp, t } from "@futurity/plugins";
1
+ import { cors, mcp, t } from "@futurity/plugins";
2
+ import type { PluginManifestOptions } from "@futurity/plugins";
3
+ import { forwardedAuth, requireAccessToken, tokenEndpoint } from "./auth";
4
+
5
+ const PORT = Number(process.env.PORT ?? 3000);
6
+
7
+ // Futurity reads the manifest and calls the tools from its own servers, so the
8
+ // URLs the manifest publishes must be the ones Futurity can reach.
9
+ const PUBLIC_URL = process.env.PUBLIC_URL ?? `http://localhost:${PORT}`;
10
+ const TOKEN_PATH = "/oauth/token";
11
+
12
+ const signingKey = process.env.FUTURITY_SIGNING_KEY;
13
+ if (!signingKey) {
14
+ console.warn(
15
+ "FUTURITY_SIGNING_KEY is not set, so /.well-known/futurity/plugin is not served.\n" +
16
+ "Run `bun run keygen`, put the private key in .env, and restart to register this plugin.",
17
+ );
18
+ }
19
+
20
+ const manifest: PluginManifestOptions | undefined = signingKey
21
+ ? {
22
+ specVersion: 2,
23
+ pluginId: "{{slug}}",
24
+ name: "{{name}}",
25
+ version: "0.1.0",
26
+ mcpUrl: `${PUBLIC_URL}/mcp`,
27
+ auth: {
28
+ type: "forwarding",
29
+ grantType: "client_credentials",
30
+ tokenEndpoint: `${PUBLIC_URL}${TOKEN_PATH}`,
31
+ requiredScopes: [],
32
+ deliveryMethod: "header",
33
+ },
34
+ signingKey,
35
+ }
36
+ : undefined;
2
37
 
3
38
  const app = mcp({
4
- name: "my-plugin",
39
+ name: "{{slug}}",
5
40
  version: "0.1.0",
41
+ pluginManifest: manifest,
6
42
  });
7
43
 
44
+ app.use(cors()).middleware(tokenEndpoint(TOKEN_PATH)).middleware(forwardedAuth);
45
+
46
+ // A tool is a name, a description, an input schema, and a handler. The agent
47
+ // reads the description alone when it chooses a tool, so write it as *when to
48
+ // use this*, not as what it does internally.
8
49
  app.tool("hello", {
9
- description: "Say hello",
50
+ description: "Use when someone asks this plugin to greet a person by name.",
10
51
  input: t.obj({ name: t.str }),
11
- handler: async ({ name }) => ({
12
- greeting: `Hello, ${name}!`,
13
- }),
52
+ handler: async ({ name }) => ({ greeting: `Hello, ${name}!` }),
53
+ });
54
+
55
+ // Delete this one once your own tools call a real API. `requireAccessToken()`
56
+ // returns the credential Futurity forwarded for the caller.
57
+ app.tool("whoami", {
58
+ description:
59
+ "Use to prove the connection works. Returns the identity behind the forwarded credentials.",
60
+ input: t.obj({}),
61
+ handler: async () => {
62
+ const token = requireAccessToken();
63
+ // YOUR CODE HERE: call the system you wrap with this token.
64
+ return { connected: true, tokenPreview: `${token.slice(0, 6)}…` };
65
+ },
14
66
  });
15
67
 
16
- await app.listen(3000);
68
+ await app.listen(PORT);