@noodleseed/agent-kit 0.35.0 → 0.37.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/manifest.json +75 -15
- package/package.json +1 -1
- package/skills/claude-code/SKILL.md +1 -1
- package/skills/claude-code/examples/customer-auth/README.md +7 -0
- package/skills/claude-code/examples/gmail-multi-account/README.md +46 -0
- package/skills/claude-code/examples/gmail-multi-account/noodle.json +4 -0
- package/skills/claude-code/examples/gmail-multi-account/package.json +16 -0
- package/skills/claude-code/examples/gmail-multi-account/src/server.ts +485 -0
- package/skills/claude-code/examples/gmail-multi-account/test/server.test.ts +271 -0
- package/skills/claude-code/examples/gmail-multi-account/vitest.config.ts +28 -0
- package/skills/claude-code/references/authoring-workflow.md +3 -1
- package/skills/claude-code/references/compile-errors.md +4 -0
- package/skills/claude-code/references/embedded-assistant.md +2 -1
- package/skills/claude-code/references/examples.md +1 -0
- package/skills/claude-code/references/sdk-surface.md +6 -0
- package/skills/codex/SKILL.md +1 -1
- package/skills/codex/examples/customer-auth/README.md +7 -0
- package/skills/codex/examples/gmail-multi-account/README.md +46 -0
- package/skills/codex/examples/gmail-multi-account/noodle.json +4 -0
- package/skills/codex/examples/gmail-multi-account/package.json +16 -0
- package/skills/codex/examples/gmail-multi-account/src/server.ts +485 -0
- package/skills/codex/examples/gmail-multi-account/test/server.test.ts +271 -0
- package/skills/codex/examples/gmail-multi-account/vitest.config.ts +28 -0
- package/skills/codex/references/authoring-workflow.md +3 -1
- package/skills/codex/references/compile-errors.md +4 -0
- package/skills/codex/references/embedded-assistant.md +2 -1
- package/skills/codex/references/examples.md +1 -0
- package/skills/codex/references/sdk-surface.md +6 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
compileManifest,
|
|
7
|
+
InMemoryCatalog,
|
|
8
|
+
validateJsonSchemaWithDefaults,
|
|
9
|
+
} from '../../../packages/compiler/src/index.js';
|
|
10
|
+
import { compileConnectors } from '../../../packages/connector-defs/src/index.js';
|
|
11
|
+
import {
|
|
12
|
+
type CredentialBroker,
|
|
13
|
+
type CredentialRequest,
|
|
14
|
+
type DownstreamCredential,
|
|
15
|
+
executePreparedTool,
|
|
16
|
+
executeTool,
|
|
17
|
+
InMemoryConnectorRegistry,
|
|
18
|
+
isConfirmationRequired,
|
|
19
|
+
prepareToolForConfirmation,
|
|
20
|
+
} from '../../../packages/runtime/src/index.js';
|
|
21
|
+
import app, { PERSONAL_ACCOUNT, WORK_ACCOUNT } from '../src/server.js';
|
|
22
|
+
|
|
23
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
|
|
25
|
+
async function compiledFixture() {
|
|
26
|
+
const catalog = structuredClone(app.toConnectorCatalog());
|
|
27
|
+
if (catalog === undefined) throw new Error('expected connector catalog');
|
|
28
|
+
for (const connector of catalog.connectors) {
|
|
29
|
+
if ('http' in connector) {
|
|
30
|
+
for (const [name, operation] of Object.entries(connector.operations)) {
|
|
31
|
+
operation.fake = {
|
|
32
|
+
response: {
|
|
33
|
+
id: `${name}-fixture`,
|
|
34
|
+
messages: [{ id: `${name}-message` }],
|
|
35
|
+
drafts: [{ id: `${name}-draft` }],
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const connectors = compileConnectors(JSON.stringify(catalog), { mode: 'fake' });
|
|
42
|
+
if (!connectors.ok) throw new Error(JSON.stringify(connectors.errors));
|
|
43
|
+
const manifest = await app.toManifest();
|
|
44
|
+
const compiled = compileManifest(manifest, {
|
|
45
|
+
catalog: new InMemoryCatalog(connectors.catalog),
|
|
46
|
+
});
|
|
47
|
+
if (!compiled.ok) throw new Error(JSON.stringify(compiled.errors));
|
|
48
|
+
const requests: CredentialRequest[] = [];
|
|
49
|
+
const broker: CredentialBroker = {
|
|
50
|
+
getCredential(request): Promise<DownstreamCredential> {
|
|
51
|
+
requests.push(request);
|
|
52
|
+
return Promise.resolve({ token: `${request.bindingId}-fixture-token` });
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
artifact: compiled.artifact,
|
|
57
|
+
deps: { connectors: new InMemoryConnectorRegistry(connectors.connectors), broker },
|
|
58
|
+
requests,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('multi-account Gmail flagship', () => {
|
|
63
|
+
it('binds the same curated connector twice without provider ids, credentials, or real labels', async () => {
|
|
64
|
+
const manifest = await app.toManifest();
|
|
65
|
+
expect(manifest.connectors).toMatchObject({
|
|
66
|
+
personal_gmail: {
|
|
67
|
+
id: 'gmail',
|
|
68
|
+
binding: {
|
|
69
|
+
profile: 'user_oauth',
|
|
70
|
+
connection: { id: 'personal_gmail', source: { kind: 'externalExchange' } },
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
work_gmail: {
|
|
74
|
+
id: 'gmail',
|
|
75
|
+
binding: {
|
|
76
|
+
profile: 'user_oauth',
|
|
77
|
+
connection: { id: 'work_gmail', source: { kind: 'externalExchange' } },
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
const wire = JSON.stringify({ manifest, catalog: app.toConnectorCatalog() });
|
|
82
|
+
expect(wire).not.toMatch(/access[_-]?token|client[_-]?secret/i);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('emits enforceable canonical account arrays on every tool', async () => {
|
|
86
|
+
const manifest = await app.toManifest();
|
|
87
|
+
for (const tool of manifest.tools) {
|
|
88
|
+
expect(tool.inputSchema.properties).toHaveProperty('accounts');
|
|
89
|
+
expect(JSON.stringify(tool.inputSchema.properties.accounts)).toContain(PERSONAL_ACCOUNT);
|
|
90
|
+
expect(JSON.stringify(tool.inputSchema.properties.accounts)).toContain(WORK_ACCOUNT);
|
|
91
|
+
}
|
|
92
|
+
const read = manifest.tools.find((tool) => tool.name === 'search_messages');
|
|
93
|
+
const write = manifest.tools.find((tool) => tool.name === 'trash_message');
|
|
94
|
+
const readAccountsSchema = JSON.stringify(read?.inputSchema.properties.accounts);
|
|
95
|
+
const writeAccountsSchema = JSON.stringify(write?.inputSchema.properties.accounts);
|
|
96
|
+
expect(readAccountsSchema).toContain(PERSONAL_ACCOUNT);
|
|
97
|
+
expect(readAccountsSchema).toContain(WORK_ACCOUNT);
|
|
98
|
+
expect(readAccountsSchema).toContain('"maxItems":2');
|
|
99
|
+
expect(writeAccountsSchema).not.toContain('"maxItems":2');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('allows subject-only or plain-body-only vacation replies but rejects an empty enabled reply', async () => {
|
|
103
|
+
const manifest = await app.toManifest();
|
|
104
|
+
const schema = manifest.tools.find((tool) => tool.name === 'update_vacation')?.inputSchema;
|
|
105
|
+
if (schema === undefined) throw new Error('expected update_vacation schema');
|
|
106
|
+
const base = { accounts: [PERSONAL_ACCOUNT], settings: { enable_auto_reply: true } };
|
|
107
|
+
|
|
108
|
+
expect(
|
|
109
|
+
validateJsonSchemaWithDefaults(schema, {
|
|
110
|
+
...base,
|
|
111
|
+
settings: { ...base.settings, response_subject: 'Away' },
|
|
112
|
+
}).issues,
|
|
113
|
+
).toHaveLength(0);
|
|
114
|
+
expect(
|
|
115
|
+
validateJsonSchemaWithDefaults(schema, {
|
|
116
|
+
...base,
|
|
117
|
+
settings: { ...base.settings, response_body_plain_text: 'Back soon' },
|
|
118
|
+
}).issues,
|
|
119
|
+
).toHaveLength(0);
|
|
120
|
+
expect(validateJsonSchemaWithDefaults(schema, base).issues).not.toHaveLength(0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it.each([
|
|
124
|
+
[],
|
|
125
|
+
['unknown@example.com'],
|
|
126
|
+
[PERSONAL_ACCOUNT, PERSONAL_ACCOUNT],
|
|
127
|
+
[WORK_ACCOUNT, PERSONAL_ACCOUNT],
|
|
128
|
+
])('rejects invalid read accounts %j before connector dispatch', async (accounts) => {
|
|
129
|
+
const setup = await compiledFixture();
|
|
130
|
+
const schema = setup.artifact.tools.find(
|
|
131
|
+
(candidate) => candidate.name === 'search_messages',
|
|
132
|
+
)?.inputSchema;
|
|
133
|
+
if (!schema) throw new Error('expected search schema');
|
|
134
|
+
const result = validateJsonSchemaWithDefaults(schema, { accounts, query: '' });
|
|
135
|
+
expect(result.issues).not.toHaveLength(0);
|
|
136
|
+
expect(setup.requests).toHaveLength(0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('rejects multi-account mutations at the write schema boundary', async () => {
|
|
140
|
+
const setup = await compiledFixture();
|
|
141
|
+
const schema = setup.artifact.tools.find(
|
|
142
|
+
(candidate) => candidate.name === 'trash_message',
|
|
143
|
+
)?.inputSchema;
|
|
144
|
+
if (!schema) throw new Error('expected trash schema');
|
|
145
|
+
|
|
146
|
+
const result = validateJsonSchemaWithDefaults(schema, {
|
|
147
|
+
accounts: [PERSONAL_ACCOUNT, WORK_ACCOUNT],
|
|
148
|
+
message_id: 'fixture-message',
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(result.issues).not.toHaveLength(0);
|
|
152
|
+
expect(setup.requests).toHaveLength(0);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('rejects a no-op label mutation before confirmation or credential lookup', async () => {
|
|
156
|
+
const setup = await compiledFixture();
|
|
157
|
+
|
|
158
|
+
await expect(
|
|
159
|
+
prepareToolForConfirmation(
|
|
160
|
+
setup.artifact,
|
|
161
|
+
'modify_message_labels',
|
|
162
|
+
{ accounts: [PERSONAL_ACCOUNT], message_id: 'fixture-message' },
|
|
163
|
+
setup.deps,
|
|
164
|
+
),
|
|
165
|
+
).resolves.toMatchObject({ status: 'failed', error: { code: 'arg_invalid' } });
|
|
166
|
+
expect(setup.requests).toHaveLength(0);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('routes personal, work, and combined reads to isolated bindings and preserves account labels', async () => {
|
|
170
|
+
const personal = await compiledFixture();
|
|
171
|
+
const personalResult = await executeTool(
|
|
172
|
+
personal.artifact,
|
|
173
|
+
'search_messages',
|
|
174
|
+
{ accounts: [PERSONAL_ACCOUNT], query: 'is:unread' },
|
|
175
|
+
personal.deps,
|
|
176
|
+
);
|
|
177
|
+
expect(personal.requests.flatMap((request) => request.bindingId ?? [])).toEqual([
|
|
178
|
+
'personal_gmail',
|
|
179
|
+
]);
|
|
180
|
+
expect(personalResult).toMatchObject({
|
|
181
|
+
ok: true,
|
|
182
|
+
output: { results: [{ account: PERSONAL_ACCOUNT }] },
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const work = await compiledFixture();
|
|
186
|
+
const workResult = await executeTool(
|
|
187
|
+
work.artifact,
|
|
188
|
+
'search_messages',
|
|
189
|
+
{ accounts: [WORK_ACCOUNT], query: 'is:unread' },
|
|
190
|
+
work.deps,
|
|
191
|
+
);
|
|
192
|
+
expect(work.requests.flatMap((request) => request.bindingId ?? [])).toEqual(['work_gmail']);
|
|
193
|
+
expect(workResult).toMatchObject({
|
|
194
|
+
ok: true,
|
|
195
|
+
output: { results: [{ account: WORK_ACCOUNT }] },
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const both = await compiledFixture();
|
|
199
|
+
const bothResult = await executeTool(
|
|
200
|
+
both.artifact,
|
|
201
|
+
'search_messages',
|
|
202
|
+
{ accounts: [PERSONAL_ACCOUNT, WORK_ACCOUNT], query: 'is:unread' },
|
|
203
|
+
both.deps,
|
|
204
|
+
);
|
|
205
|
+
expect(both.requests.flatMap((request) => request.bindingId ?? [])).toEqual([
|
|
206
|
+
'personal_gmail',
|
|
207
|
+
'work_gmail',
|
|
208
|
+
]);
|
|
209
|
+
expect(bothResult).toMatchObject({
|
|
210
|
+
ok: true,
|
|
211
|
+
output: {
|
|
212
|
+
results: [{ account: PERSONAL_ACCOUNT }, { account: WORK_ACCOUNT }],
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('prepares the exact selected write binding, dispatches only after confirmation, and rejects replay drift', async () => {
|
|
218
|
+
const setup = await compiledFixture();
|
|
219
|
+
const prepared = await prepareToolForConfirmation(
|
|
220
|
+
setup.artifact,
|
|
221
|
+
'trash_message',
|
|
222
|
+
{ accounts: [WORK_ACCOUNT], message_id: 'fixture-message' },
|
|
223
|
+
setup.deps,
|
|
224
|
+
);
|
|
225
|
+
expect(isConfirmationRequired(prepared)).toBe(true);
|
|
226
|
+
if (!isConfirmationRequired(prepared)) throw new Error('expected confirmation');
|
|
227
|
+
expect(setup.requests).toHaveLength(0);
|
|
228
|
+
expect(prepared.review.action).toMatchObject({
|
|
229
|
+
bindingId: 'work_gmail',
|
|
230
|
+
connectionId: 'work_gmail',
|
|
231
|
+
operation: 'trash_message',
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await expect(
|
|
235
|
+
executePreparedTool(setup.artifact, prepared.continuation, setup.deps),
|
|
236
|
+
).resolves.toMatchObject({ status: 'completed' });
|
|
237
|
+
expect(setup.requests.flatMap((request) => request.bindingId ?? [])).toEqual(['work_gmail']);
|
|
238
|
+
|
|
239
|
+
const personalPrepared = structuredClone(prepared.continuation);
|
|
240
|
+
if (personalPrepared.reviewedAction) {
|
|
241
|
+
(personalPrepared.reviewedAction as { bindingId?: string }).bindingId = 'personal_gmail';
|
|
242
|
+
}
|
|
243
|
+
await expect(
|
|
244
|
+
executePreparedTool(setup.artifact, personalPrepared, setup.deps),
|
|
245
|
+
).resolves.toMatchObject({ status: 'failed' });
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('makes every mutation confirmable and keeps the personal-email skill concise and safe', async () => {
|
|
249
|
+
const manifest = await app.toManifest();
|
|
250
|
+
const catalog = app.toConnectorCatalog();
|
|
251
|
+
const actionNames = new Set(
|
|
252
|
+
catalog?.connectors.flatMap((connector) =>
|
|
253
|
+
Object.entries(connector.operations)
|
|
254
|
+
.filter(([, operation]) => operation.type === 'action')
|
|
255
|
+
.map(([name]) => name),
|
|
256
|
+
),
|
|
257
|
+
);
|
|
258
|
+
for (const tool of manifest.tools.filter((candidate) => actionNames.has(candidate.name))) {
|
|
259
|
+
expect(tool.annotations?.confirm, tool.name).toBe(true);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const skill = readFileSync(join(here, '../skills/personal-email-automation/SKILL.md'), 'utf8');
|
|
263
|
+
expect(skill).toMatch(/^---\nname: personal-email-automation\ndescription:/);
|
|
264
|
+
expect(skill.split('\n').length).toBeLessThan(140);
|
|
265
|
+
expect(skill).toContain('accounts');
|
|
266
|
+
expect(skill).toMatch(/draft/i);
|
|
267
|
+
expect(skill).toMatch(/confirm/i);
|
|
268
|
+
expect(skill).toMatch(/permanent delete/i);
|
|
269
|
+
expect(skill).not.toMatch(/client[_-]?secret|access[_-]?token/i);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
resolve: {
|
|
5
|
+
alias: {
|
|
6
|
+
'@noodleseed/one': new URL('../../packages/authoring/src/index.ts', import.meta.url).pathname,
|
|
7
|
+
'@noodle-borg/capabilities': new URL(
|
|
8
|
+
'../../packages/capabilities/src/index.ts',
|
|
9
|
+
import.meta.url,
|
|
10
|
+
).pathname,
|
|
11
|
+
'@noodle-borg/compiler': new URL('../../packages/compiler/src/index.ts', import.meta.url)
|
|
12
|
+
.pathname,
|
|
13
|
+
'@noodle-borg/compute': new URL('../../packages/compute/src/index.ts', import.meta.url)
|
|
14
|
+
.pathname,
|
|
15
|
+
'@noodle-borg/connector-defs': new URL(
|
|
16
|
+
'../../packages/connector-defs/src/index.ts',
|
|
17
|
+
import.meta.url,
|
|
18
|
+
).pathname,
|
|
19
|
+
'@noodle-borg/connector-http': new URL(
|
|
20
|
+
'../../packages/connector-http/src/index.ts',
|
|
21
|
+
import.meta.url,
|
|
22
|
+
).pathname,
|
|
23
|
+
'@noodle-borg/runtime': new URL('../../packages/runtime/src/index.ts', import.meta.url)
|
|
24
|
+
.pathname,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
test: { include: ['test/**/*.test.ts'] },
|
|
28
|
+
});
|
|
@@ -41,6 +41,8 @@ Tools record connector calls into a flow; recording is not execution. Do not bra
|
|
|
41
41
|
|
|
42
42
|
HTTP connector auth variants: `bearer` (`{ kind: "bearer", secret: secret("API_TOKEN") }`), `apiKey` (`{ kind: "apiKey", header: "X-API-Key", secret: secret("API_KEY") }`), `clientCredentials`, `delegatedOAuth`, `delegatedSessionCookie`, and `delegatedTokenExchange` (per-user calls to your own API — see "Delegated downstream auth" below). Use managed `secret(...)` / `variable(...)` refs for all values that differ by org/app/env.
|
|
43
43
|
|
|
44
|
+
When one connector needs independently selectable accounts, declare catalog `credentialProfiles` plus each operation’s accepted `credentials.profiles`, then bind each `server.use` alias with `bind(connector, { profile, connection: connection("logical_id", managedSecret(secret("NAME"), { scopes, audience })) })`. The alias is the stable account boundary; never put provider account ids, labels, or credential values in it. `gmailConnector()` is the curated Gmail catalog helper; reuse it under independent aliases and accept canonical `accounts` arrays in tools (one account for writes, or an explicitly ordered supported combination for reads). See the bundled `gmail-multi-account` flagship. Bound managed secrets are supported by hosted execution. `externalExchange()` is runnable only when the deployment operator injects an exact HTTPS provider endpoint/origin/audience and durable shared subject-pin store through service ports; Noodle sends a short-lived platform-signed deployment workload assertion and accepts only a bounded bearer response. Provider implementations must consume assertion replay ids through durable shared atomic storage across instances and restarts. There is intentionally no hosted enrollment or provider CRUD surface yet. The provider wire contract is public, but its conformance kit is workspace/source-only and is not an installable npm package. Bound `clientCredentials(...)` remains fail-closed until its provider slice lands.
|
|
45
|
+
|
|
44
46
|
## HTTP connector example (full server)
|
|
45
47
|
|
|
46
48
|
Declare the API as data, bind it with `use`, then record calls in tools. The operation mapping in detail: `request` builds the JSON request body, `query: [...]` names the input args sent as URL query parameters, and `response` maps the parsed HTTP body (bound to `${response}`) into your typed `output`. `auth` reads a managed `secret(...)` — never inline a key. This whole example is compile-verified on every `pnpm test`.
|
|
@@ -273,7 +275,7 @@ tool('prepare_time_off', {
|
|
|
273
275
|
});
|
|
274
276
|
```
|
|
275
277
|
|
|
276
|
-
Use a stable lowercase/number/underscore id and a flat form of string/number/integer/boolean, string choices or multi-select, with optional `email`, `uri`, `date`, or `date-time` formats. Nested objects and credential-shaped fields fail with `invalid_elicitation_schema`. Every interactive flow must place all `ctx.elicit` calls before its first connector operation or compilation fails with `invalid_elicitation_flow`. Embedded/headless clients receive `input_requested`; bidirectional MCP transports map the primitive to standard form `elicitation/create`. On stateless hosts, the adapter returns a structured non-executing `interaction_unavailable` result; linked Apps render its business-user form and retry in request `_meta`, while models can use the advertised reserved retry field. Accept validates and replays only the operation-free input prefix; invalid content returns `arg_invalid`, and decline/cancel stop. Elicitation gathers missing input and does not replace confirmation. In a flow marked `confirm: true`, every eligible `input_requested` precedes `tool_proposed`; the final proposal reviews the original input, elicited values, and sole exact connector action.
|
|
278
|
+
Use a stable lowercase/number/underscore id and a flat form of string/number/integer/boolean, string choices or multi-select, with optional `email`, `uri`, `date`, or `date-time` formats. Nested objects and credential-shaped fields fail with `invalid_elicitation_schema`. Every interactive flow must place all `ctx.elicit` calls before its first connector operation or compilation fails with `invalid_elicitation_flow`. Embedded/headless clients receive `input_requested`; bidirectional MCP transports map the primitive to standard form `elicitation/create`. On stateless hosts, the adapter returns a structured non-executing `interaction_unavailable` result; linked Apps render its business-user form and retry in request `_meta`, while models can use the advertised reserved retry field. Accept validates and replays only the operation-free input prefix; invalid content returns `arg_invalid`, and decline/cancel stop. Elicitation gathers missing input and does not replace confirmation. In a flow marked `confirm: true`, every eligible `input_requested` precedes `tool_proposed`; the final proposal reviews the original input, elicited values, and sole exact eligible connector action. Conditional branches may declare multiple candidate actions only when preparation resolves exactly one eligible action from input/context or completed pure steps; zero or multiple actions fail with `invalid_confirmation_flow` before I/O. Acceptance is bound to that exact action. Later read-only operations and pure compute may assemble output, but a second eligible action fails closed. Use `ref.at(index)` for array access in recorded expressions. MCP uses final standard form confirmation on capable bidirectional transports and fails closed otherwise. Setting `interactions: { confirmationFallback: "host" }` in the server options explicitly trusts native host approval only when confirmation transport is unavailable and after every elicited field is collected; it still uses preparation and prepared execution, is never inferred from client name, and does not replace authorization. Omitted or `false` annotations execute directly; hints alone never gate. `annotations.action({ confirm: true })` explicitly enables confirmation; `annotations.action({ confirm: false })` explicitly preserves direct execution.
|
|
277
279
|
|
|
278
280
|
## Compute connector example
|
|
279
281
|
|
|
@@ -59,5 +59,9 @@ Run `noodle validate` (add `--json` for the machine-readable envelope, `--fix-pr
|
|
|
59
59
|
| `unknown_connector_alias` | The tool calls a connector alias not declared in `use`/`provides`; add it or fix the alias (see `suggestions`). |
|
|
60
60
|
| `connector_not_in_catalog` | The referenced connector is not in the resolved catalog; add it to the project connectors or correct the reference. |
|
|
61
61
|
| `unknown_operation` | The connector has no such operation; use an operation declared on that connector (see `didYouMean`/`suggestions`). |
|
|
62
|
+
| `connector_binding_required` | Bind the connector alias with `bind(connector, { profile, connection })`; credential-requiring operations cannot use an unbound alias. |
|
|
63
|
+
| `unsupported_credential_profile` | Select a credential profile declared by the connector and accepted by the operation; use the reported suggestions instead of inventing a profile name. |
|
|
64
|
+
| `credential_scope_mismatch` | Declare a connection source whose scopes include every operation-required scope, or select an external exchange provider that can mint them. |
|
|
65
|
+
| `credential_audience_mismatch` | Set the connection source audience to the operation-required audience exactly, or use an external exchange provider that can mint it. |
|
|
62
66
|
| `unused_connector_alias` | A declared connector alias is never called; remove the unused `use` entry or wire it into a tool. |
|
|
63
67
|
| `arg_mismatch` | A connector call is missing or adds arguments; match the operation signature under `expected`/`got`. |
|
|
@@ -184,7 +184,7 @@ The callback records declarative fulfilment at author time; the shared runtime e
|
|
|
184
184
|
|
|
185
185
|
## Structured missing input
|
|
186
186
|
|
|
187
|
-
A tool authored with `ctx.elicit({ id, message, input })` produces `input_requested` when it reaches missing input. Built-in and headless renderers present it and call `respond(id, { action: "accept", content })`; decline/cancel stop. Accepted content is schema-validated and completed steps are not rerun; invalid content returns `arg_invalid` and keeps the interaction pending. Elicitation gathers an input and does not approve a later write. Every interactive flow collects elicited input before its first connector operation; every eligible `input_requested` precedes `tool_proposed`. In a `confirm: true` flow, the final proposal reviews original input, elicited values, and the sole exact connector action
|
|
187
|
+
A tool authored with `ctx.elicit({ id, message, input })` produces `input_requested` when it reaches missing input. Built-in and headless renderers present it and call `respond(id, { action: "accept", content })`; decline/cancel stop. Accepted content is schema-validated and completed steps are not rerun; invalid content returns `arg_invalid` and keeps the interaction pending. Elicitation gathers an input and does not approve a later write. Every interactive flow collects elicited input before its first connector operation; every eligible `input_requested` precedes `tool_proposed`. In a `confirm: true` flow, the final proposal reviews original input, elicited values, and the sole exact eligible connector action. Conditional branches may declare candidate actions and later reads only when preparation resolves exactly one action, discloses later eligible operations, and fails with `invalid_confirmation_flow` for zero or multiple actions. Accept is bound to that action. Bidirectional MCP maps missing input to standard `elicitation/create`. On a stateless host, a linked MCP App presents the same normal-user form and re-calls the tool through standard `tools/call`, carrying replay answers in request `_meta` so approval copy contains only business fields; without Apps, the model receives the exact structured schema and an advertised reserved retry field. Both paths replay only the operation-free input prefix and never expose runtime continuation or environment state. Setting `interactions: { confirmationFallback: "host" }` explicitly trusts native host approval only after every elicited field is collected and only when confirmation transport is unavailable; it still uses the same prepared-action safety path. Embedded/headless confirmation remains Noodle-owned. Omitted or false annotations execute directly; hints never gate.
|
|
188
188
|
|
|
189
189
|
## Verified session context (identity and claims)
|
|
190
190
|
|
|
@@ -314,6 +314,7 @@ if (pendingId) {
|
|
|
314
314
|
| Symptom | Diagnosis | Fix |
|
|
315
315
|
| --- | --- | --- |
|
|
316
316
|
| Widget renders but no reply arrives and model usage stays zero | Turns are not reaching the service: outdated `@noodleseed/assistant` package, or the session response was rebuilt/filtered by the backend route | Update the package to the latest version; forward the session response unchanged |
|
|
317
|
+
| Widget card shows its title but an empty/blank frame (or a "could not be displayed" note) | The widget frame could not complete its bridge handshake: the page CSP blocks the hosted sandbox document (`frame-src`), the SDK predates hosted-sandbox rendering, or the deployed artifact was compiled by a now-incompatible CLI | Add the service origin to the page's `frame-src` (and `connect-src`) CSP directives; update `@noodleseed/assistant`; redeploy the app with the current `noodle` CLI |
|
|
317
318
|
| `assistant-error` with code `invalid_response` | The turn endpoint returned HTML or non-SSE content (auth redirect, proxy page) | Check the backend session route path and any middleware/rewrites on the embedding app |
|
|
318
319
|
| Build error `Package path ./react is not exported` | Outdated package version with import-only export conditions | Update `@noodleseed/assistant`; do not add webpack aliases or type shims |
|
|
319
320
|
| Deploy fails with `server_auth_required` | `--access customers` without `server.auth` | Add direct/federated OIDC or a built-in Firebase/Microsoft adapter |
|
|
@@ -15,6 +15,7 @@ Paths are relative to this skill directory. Assets (images/fonts) are omitted fr
|
|
|
15
15
|
| `acme-tasks` | A two-way productivity app designed around its top-3 prioritized flows (capture/prioritize/complete), with a design-first flow spec + wireframe. | `examples/acme-tasks/src/server.ts` + `design/` |
|
|
16
16
|
| `acme-bistro` | End-to-end ordering with a payment-only handoff; ships a gold-standard `design/` set (UX doc, wireframe with compliance audit, API contract). | `examples/acme-bistro/src/server.ts` + `design/` |
|
|
17
17
|
| `customer-auth` | End-user (customer) auth via OIDC/Firebase bridge with delegated credentials. | `examples/customer-auth/src/server.ts` |
|
|
18
|
+
| `gmail-multi-account` | One curated Gmail connector reused by two account bindings, canonical account arrays, exact mutation confirmation, and an accompanying personal-automation skill. | `examples/gmail-multi-account/src/server.ts` |
|
|
18
19
|
|
|
19
20
|
## In the repository only — `examples/<name>/` on GitHub
|
|
20
21
|
|
|
@@ -50,7 +50,13 @@ Platform helper connectors are explicit subpath imports from `@noodleseed/one/pl
|
|
|
50
50
|
|
|
51
51
|
### Other
|
|
52
52
|
|
|
53
|
+
- `bind`
|
|
54
|
+
- `clientCredentials`
|
|
55
|
+
- `connection`
|
|
53
56
|
- `embeddedAssistant`
|
|
57
|
+
- `externalExchange`
|
|
58
|
+
- `gmailConnector`
|
|
59
|
+
- `managedSecret`
|
|
54
60
|
- `openAICompatible`
|
|
55
61
|
|
|
56
62
|
## Authoring signatures
|
package/skills/codex/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: noodle-seed
|
|
|
3
3
|
description: Use when building, validating, testing, deploying, or operating a local or hosted Noodle Seed MCP server or app authored in TypeScript with the noodle CLI.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
<!-- noodle-skill version:0.
|
|
6
|
+
<!-- noodle-skill version:0.37.0 hash:75dd0f6f85b04858 -->
|
|
7
7
|
|
|
8
8
|
# Noodle Seed
|
|
9
9
|
|
|
@@ -29,6 +29,13 @@ callback.
|
|
|
29
29
|
|
|
30
30
|
## How delegated customer credentials are used
|
|
31
31
|
|
|
32
|
+
This flagship uses the legacy Firebase provider bridge because it is runnable today. That is distinct from
|
|
33
|
+
Core-v2 account-selecting connector aliases: those declare catalog `credentialProfiles` and bind each alias
|
|
34
|
+
with `bind(connector, { profile, connection })`. Hosted bound `managedSecret(...)` connections are runnable;
|
|
35
|
+
bound `externalExchange()` and `clientCredentials(...)` currently compile as portable metadata but fail
|
|
36
|
+
closed before secret or network access until their provider slice lands. Do not replace this example's
|
|
37
|
+
working delegated-session-cookie path with either unsupported bound exchange source.
|
|
38
|
+
|
|
32
39
|
The example has two declarations that work together:
|
|
33
40
|
|
|
34
41
|
```ts
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Gmail multi-account automation
|
|
2
|
+
|
|
3
|
+
**Owns:** The flagship proof that one reusable connector can be bound to multiple independently
|
|
4
|
+
authenticated accounts inside one MCP server.
|
|
5
|
+
|
|
6
|
+
This fictional example binds `gmailConnector()` twice through separate `externalExchange()` logical
|
|
7
|
+
connections. Public tools always accept `accounts: [...]`; reads accept either account or the canonical
|
|
8
|
+
personal-then-work pair, while mutations accept exactly one account and require runtime confirmation.
|
|
9
|
+
|
|
10
|
+
Capability slot: **reusable connector + independently authenticated multi-account bindings**. It is distinct
|
|
11
|
+
from `customer-auth`, which owns authentication of the MCP caller rather than downstream connector accounts.
|
|
12
|
+
|
|
13
|
+
The labels `personal@example.com` and `work@example.com` are static display labels, not provider identities.
|
|
14
|
+
The deployment-owned credential provider maps each logical connection to its real Google authorization.
|
|
15
|
+
No Google client, provider account id, token, or real email address belongs in this project.
|
|
16
|
+
|
|
17
|
+
## Safety and API boundary
|
|
18
|
+
|
|
19
|
+
- Search, message/thread reads, draft reads, and vacation-setting reads may target one or both accounts.
|
|
20
|
+
- Draft creation/update/send, label changes, archive, raw send, trash, and vacation updates target one account.
|
|
21
|
+
- Every mutation is prepared against the exact selected binding and must be confirmed before dispatch.
|
|
22
|
+
- `send_message.raw` and draft `raw` are RFC 2822 MIME bytes encoded with base64url. This example does not
|
|
23
|
+
pretend that `to`/`subject`/`body` strings are sufficient to encode Unicode MIME correctly.
|
|
24
|
+
- Vacation `startTime`/`endTime` schemas enforce only digit-shaped 1–19 character epoch-millisecond strings.
|
|
25
|
+
When both are supplied, Gmail's backend remains authoritative for the required `startTime < endTime`
|
|
26
|
+
relationship; this example does not claim cross-field JSON Schema validation.
|
|
27
|
+
- Trash is reversible. Permanent message/thread/draft deletion, delegation, forwarding/sharing settings,
|
|
28
|
+
and unrestricted raw HTTP requests are intentionally absent.
|
|
29
|
+
|
|
30
|
+
## Local checks
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
noodle validate
|
|
34
|
+
noodle test
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The committed tests compile hermetic fake connector responses. They never contact Gmail or load OAuth
|
|
38
|
+
credentials. A real deployment additionally needs an operator-provided external credential exchange
|
|
39
|
+
endpoint for each logical connection.
|
|
40
|
+
|
|
41
|
+
## Personal automation skill
|
|
42
|
+
|
|
43
|
+
The source skill is [`skills/personal-email-automation/SKILL.md`](skills/personal-email-automation/SKILL.md).
|
|
44
|
+
Validate it with the standard skill validator before distribution. The source skill is shipped as part of
|
|
45
|
+
this example; canonical export of an app and its skill as an installable Codex plugin remains roadmap work
|
|
46
|
+
and is not currently provided by Noodle Seed.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gmail-multi-account",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "vitest run",
|
|
8
|
+
"validate": "noodle validate",
|
|
9
|
+
"dev": "noodle dev",
|
|
10
|
+
"deploy": "noodle deploy"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@noodleseed/one": "latest",
|
|
14
|
+
"vitest": "latest"
|
|
15
|
+
}
|
|
16
|
+
}
|