@adrata/adrata-mcp 1.0.2 → 1.0.3

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/access/oauth.js CHANGED
@@ -104,6 +104,22 @@ export const OAUTH_WRITE_SCOPE = [
104
104
  'write:companies', 'write:people', 'write:buyer-groups',
105
105
  'write:opportunities', 'write:actions', 'write:tasks',
106
106
  'write:partnerships', 'write:sequences', 'write:campaigns', 'write:data',
107
+ // Authorising an external system to write into the workspace — connecting a
108
+ // CRM, or binding a source-control repository to a board. Its absence was not
109
+ // a deliberate least-privilege call, because `read:integrations` was already
110
+ // granted by default: the connection could SEE every integration and connect
111
+ // none of them.
112
+ //
113
+ // Measured 2026-08-29. `POST /api/v1/scm/connections` returned 403
114
+ // insufficient_scope against a session holding ten other write scopes, so the
115
+ // Starfield board could not be wired to GitHub from the only layer that has a
116
+ // connect flow at all — there is no Connections UI for source control. Every
117
+ // card move stayed manual as a result.
118
+ //
119
+ // Still opt-in: this list is only requested by connect_workspace({ writeAccess:
120
+ // true }), so the default grant remains read-only and a leaked token cannot
121
+ // authorise an integration.
122
+ 'write:integrations',
107
123
  ].join(' ');
108
124
 
109
125
  /**
@@ -576,7 +592,7 @@ async function performTokenRefresh(apiBase, fetchImpl) {
576
592
  refresh_token: tokens.refreshToken,
577
593
  // RFC 8707: keep the same audience binding the initial grant used so the
578
594
  // refreshed access token stays bound to the /api/v1/mcp resource.
579
- resource: tokens.resource || canonicalResource(),
595
+ resource: tokens.resource || canonicalResource(apiBase),
580
596
  });
581
597
 
582
598
  let lastError;
@@ -669,7 +685,7 @@ export async function getValidToken(apiBase, { forceRefresh = false, fetchImpl =
669
685
  /**
670
686
  * Build the OAuth authorization URL.
671
687
  */
672
- function buildAuthUrl(apiBase, clientId, redirectUri, state, pkce, scope = OAUTH_SCOPE) {
688
+ export function buildAuthUrl(apiBase, clientId, redirectUri, state, pkce, scope = OAUTH_SCOPE) {
673
689
  // Use the web app's OAuth authorize page (user-facing login + consent screen)
674
690
  const url = new URL(`${OAUTH_BASE_PATH}/authorize`, apiBase);
675
691
  url.searchParams.set('client_id', clientId);
@@ -680,7 +696,7 @@ function buildAuthUrl(apiBase, clientId, redirectUri, state, pkce, scope = OAUTH
680
696
  url.searchParams.set('source', 'mcp');
681
697
  // RFC 8707 Resource Indicator — request a token audience-bound to this MCP
682
698
  // resource so it cannot be replayed against a different service.
683
- url.searchParams.set('resource', canonicalResource());
699
+ url.searchParams.set('resource', canonicalResource(apiBase));
684
700
  url.searchParams.set('code_challenge', pkce.challenge);
685
701
  url.searchParams.set('code_challenge_method', 'S256');
686
702
  return { url: url.toString(), state, codeVerifier: pkce.verifier };
@@ -853,7 +869,7 @@ export function createOAuthCallbackRequestHandler({
853
869
  /**
854
870
  * Exchange an authorization code for tokens.
855
871
  */
856
- async function exchangeCode(apiBase, code, clientId, codeVerifier, redirectUri) {
872
+ export async function exchangeCode(apiBase, code, clientId, codeVerifier, redirectUri, fetchImpl = fetch) {
857
873
  const url = new URL(`${OAUTH_BASE_PATH}/token`, apiBase);
858
874
  const body = new URLSearchParams({
859
875
  grant_type: 'authorization_code',
@@ -861,10 +877,10 @@ async function exchangeCode(apiBase, code, clientId, codeVerifier, redirectUri)
861
877
  code,
862
878
  code_verifier: codeVerifier,
863
879
  redirect_uri: redirectUri,
864
- resource: canonicalResource(),
880
+ resource: canonicalResource(apiBase),
865
881
  });
866
882
 
867
- const res = await fetch(url.toString(), {
883
+ const res = await fetchImpl(url.toString(), {
868
884
  method: 'POST',
869
885
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
870
886
  body,
@@ -1026,7 +1042,7 @@ export async function connectWorkspace(apiBase, { writeAccess = false } = {}) {
1026
1042
  clientId,
1027
1043
  clientRegistration: registration.clientRegistration,
1028
1044
  tokenEndpointAuthMethod: registration.tokenEndpointAuthMethod,
1029
- resource: canonicalResource(),
1045
+ resource: canonicalResource(apiBase),
1030
1046
  apiBase,
1031
1047
  connectedAt: new Date().toISOString(),
1032
1048
  };
@@ -35,8 +35,16 @@ export function authorizationServers() {
35
35
  * The canonical resource identifier for this MCP server. RFC 8707 clients
36
36
  * send this as the `resource` parameter so the AS can bind the token's
37
37
  * audience to it. Defaults to the REST MCP resource the Rust AS advertises.
38
+ *
39
+ * Client flows pass their explicit API base so a staging authorization request
40
+ * cannot accidentally ask for a production-bound token. Hosted resource-server
41
+ * callers omit it and continue to use the configured authorization server.
42
+ *
43
+ * @param {string} [apiBase] - Explicit authorization-server/API base for a
44
+ * client connection.
38
45
  */
39
- export function canonicalResource() {
46
+ export function canonicalResource(apiBase) {
47
+ if (apiBase) return `${apiBase.replace(/\/$/, '')}/api/v1/mcp`;
40
48
  if (process.env.ADRATA_MCP_RESOURCE) return process.env.ADRATA_MCP_RESOURCE.trim();
41
49
  const as = authorizationServers()[0] || 'https://api.adrata.com';
42
50
  return `${as.replace(/\/$/, '')}/api/v1/mcp`;
package/access/tiers.js CHANGED
@@ -234,6 +234,8 @@ export const TOOL_TIERS = {
234
234
  set_work_item_kind: TIERS.ENTERPRISE,
235
235
  create_work_item: TIERS.ENTERPRISE,
236
236
  add_work_item_acceptance_criterion: TIERS.ENTERPRISE,
237
+ satisfy_work_item_acceptance_criterion: TIERS.ENTERPRISE,
238
+ unsatisfy_work_item_acceptance_criterion: TIERS.ENTERPRISE,
237
239
  comment_on_work_item: TIERS.ENTERPRISE,
238
240
  flag_work_item: TIERS.ENTERPRISE,
239
241
  // The containers above the cards, and the "add this to the roadmap" verb.
package/api-bridge.js CHANGED
@@ -171,6 +171,14 @@ const ALLOWED_PREFIXES = [
171
171
  '/api/v1/retention',
172
172
  '/api/v1/revenue',
173
173
  '/api/v1/revenue-cloud',
174
+ // Source control connections and their repository bindings. The board
175
+ // cannot learn that a pull request merged unless something can create the
176
+ // connection and register the callback, and until this row existed the
177
+ // bridge refused every /scm path — so the receiver at /webhooks/scm sat
178
+ // mounted and reachable with no connection ever pointing at it.
179
+ // The unauthenticated receiver itself is NOT here: it is mounted below the
180
+ // auth layer and is not an agent-callable path.
181
+ '/api/v1/scm',
174
182
  '/api/v1/scoring',
175
183
  '/api/v1/security',
176
184
  '/api/v1/self-service',
@@ -279,6 +287,14 @@ const PATH_WRITE_SCOPES = [
279
287
  // All three are listed separately because `matchesPathPrefix` is
280
288
  // segment-boundary matched: `/work-boards` does not annex
281
289
  // `/work-board-rollups`.
290
+ // Authorising a source-control account is the same act as connecting a CRM —
291
+ // an external system permitted to write into the workspace — so scope_guard
292
+ // maps it onto the integrations family rather than a new one. Deliberately
293
+ // not an `admin:` scope: those refuse first-party bypass and a human session
294
+ // JWT carries an empty permission list, which would 403 every person opening
295
+ // the Connections screen. Mirrors `family_matches(path, "/scm")` in
296
+ // code/api/crates/middleware/src/scope_guard/mod.rs.
297
+ ['/api/v1/scm', 'write:integrations'],
282
298
  ['/api/v1/work-boards', 'write:tasks'],
283
299
  ['/api/v1/work-items', 'write:tasks'],
284
300
  ['/api/v1/work-board-rollups', 'write:tasks'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adrata/adrata-mcp",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Adrata MCP Server \u2014 connect Claude Code, Codex, Gemini, Cursor, and other AI tools to your CRM. 80+ tools for companies, people, deals, actions, buyer groups, warm intros, webhooks, intelligence, and more.",
5
5
  "type": "module",
6
6
  "main": "server.js",
@@ -110,6 +110,28 @@ Two things to carry back to the card:
110
110
  session), so the next person at aligning can ask you what you meant; what it
111
111
  must not arrive with is a list of outcomes nobody can check.
112
112
 
113
+ ## Tick what you ran
114
+
115
+ Before you move the card on, tick the criteria you actually executed, with
116
+ `satisfy_work_item_acceptance_criterion` and the evidence in `note` — a build
117
+ number, a run link, the caveat that makes it honest. An untouched checkbox says
118
+ nothing about whether the check passed; it says nobody has been near it, which
119
+ is the same thing an abandoned card says.
120
+
121
+ Ticking from a build column records `claimed`, not `verified`, and so does
122
+ ticking your own card at a QA gate. That is the rule, not a refusal: the grade
123
+ is derived from where the card was standing and who you are, and the engineer
124
+ who built the thing cannot verify it. Tick anyway — `claimed` is a real state
125
+ that says the work is believed done, and it is what QA reads before deciding
126
+ what to re-run. What you must not do is tick a box you did not execute to make
127
+ the count look better: the QA gate reads `verified`, so it stops either way, and
128
+ all the false tick achieves is turning an honest "shipped with two open" into a
129
+ claim somebody later believes.
130
+
131
+ If a tick turns out to be wrong, `unsatisfy_work_item_acceptance_criterion`
132
+ takes it off. It clears the note with it, so if the evidence is worth keeping,
133
+ put it on the card with `comment_on_work_item` first.
134
+
113
135
  ## Submit it
114
136
 
115
137
  When the change is ready for review, `move_work_item` it onward — no `claim`
@@ -242,6 +242,12 @@ const IDEMPOTENT_WRITES = new Set([
242
242
  'move_work_item', 'set_work_board_column_wip_limit', 'set_work_item_tag', 'set_work_item_kind',
243
243
  'create_work_item', 'comment_on_work_item', 'flag_work_item',
244
244
  'add_work_item_acceptance_criterion',
245
+ // Ticking and un-ticking are both genuinely idempotent, and for different
246
+ // reasons worth keeping straight: a repeat tick is a no-op because the server
247
+ // only writes the ticker, column and note where they were empty, and a repeat
248
+ // un-tick clears columns that are already NULL. Neither can compound.
249
+ 'satisfy_work_item_acceptance_criterion',
250
+ 'unsatisfy_work_item_acceptance_criterion',
245
251
  ]);
246
252
 
247
253
  // Non-read tools that create new state / have side effects each call.
@@ -131,6 +131,19 @@ export function describeMissingAcceptanceCriteria(criteria) {
131
131
  return 'This card would have no executable acceptance criteria. It can be captured, but it is not ready for build or QA until at least one where/when/then criterion is added.';
132
132
  }
133
133
 
134
+ /**
135
+ * The satisfaction sub-resource of one criterion, POSTed to tick and DELETEd to
136
+ * un-tick.
137
+ *
138
+ * One function for both verbs because they address the SAME resource: a tick
139
+ * and its removal that disagreed about the path would fail asymmetrically —
140
+ * ticks landing and un-ticks 404ing — which is the failure that leaves a board
141
+ * with checkboxes nobody can clear.
142
+ */
143
+ export function criterionSatisfactionPath(itemId, criterionId) {
144
+ return `/api/v1/work-items/${encodeURIComponent(itemId)}/acceptance-criteria/${encodeURIComponent(criterionId)}/satisfaction`;
145
+ }
146
+
134
147
  /** One target stage's WIP state before and after a proposed move. */
135
148
  export function projectColumnWip({ limit, count, cardAlreadyThere, truncated = false }) {
136
149
  const projected = count + (cardAlreadyThere ? 0 : 1);
@@ -885,6 +898,160 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
885
898
  return ok({ added: true, criterion: data?.data });
886
899
  }
887
900
  );
901
+
902
+ server.tool(
903
+ 'satisfy_work_item_acceptance_criterion',
904
+ `Tick one acceptance criterion, with the evidence that made you believe it. This is the act that turns a checklist into proof: a criterion nobody ticks is indistinguishable from one nobody ran, and a board full of untouched checkboxes reports the same thing whether the work was verified or abandoned.
905
+
906
+ WHERE YOU TICK FROM DECIDES WHAT THE TICK IS WORTH, and it is not a permission you can ask for. The grade is derived on every read from the card's CURRENT column and from who you are:
907
+
908
+ • \`verified\` — ticked while the card stands in a QA stage ("Staging QA1" or "Staging QA2"), by somebody who neither wrote the criterion nor owns the card.
909
+ • \`claimed\` — everything else. Ticked in a build column, or ticked at a QA gate by the card's owner or by the person who authored the check. A real and useful state: it says the work is believed done. It is self-assessment, and the board never counts it as verification.
910
+
911
+ So the engineer who built the card CANNOT verify their own work, and neither can whoever wrote the criterion — not because the tick is refused, but because it grades as \`claimed\` however it is worded. Nothing here fails on that account; you get the criterion back carrying a grade, and the grade may not be the one you expected. If you want \`verified\`, the card has to be standing at a QA gate and somebody else has to be doing the ticking.
912
+
913
+ TICK WHAT YOU RAN, not what you believe. The card face prints met over total; the QA exit gate reads the VERIFIED count. So a card whose boxes were all ticked by its own author reads 5/5 on the face and still stops the gate — which is the design working. Ticking without running the check converts an honest "we shipped with two open" into a false "all met", and the false version is the one that gets believed later.
914
+
915
+ FIRST TICK WINS. The server records the ticker, the column and the note only where they were empty, so a second person ticking an already-ticked box cannot overwrite the first person's evidence, and re-ticking is a no-op rather than an upgrade. To re-tick under different circumstances, un-tick it first with unsatisfy_work_item_acceptance_criterion — that clears the ticker, the column and the note together, so the next tick cannot inherit somebody else's evidence.${GOVERNED_NOTE}`,
916
+ {
917
+ itemId: z.string().describe('Card id.'),
918
+ criterionId: z
919
+ .string()
920
+ .describe('Criterion id from list_work_item_acceptance_criteria — the `id`, not the `ordinal`.'),
921
+ note: z
922
+ .string()
923
+ .optional()
924
+ .describe(
925
+ 'OPTIONAL, and the most useful field here: the evidence. A build number, a PR or run link, or the caveat that makes the tick honest ("only checked on Safari"). Stored on the criterion and shown beside it, so it is what a release review reads instead of taking the tick on trust. Omit it when there is genuinely nothing to add — an invented note is worse than none.'
926
+ ),
927
+ dryRun: z.boolean().optional().describe('Defaults to true. Set false to record the tick.'),
928
+ approved: z.boolean().optional().describe('Required true for a live tick.'),
929
+ reason: z
930
+ .string()
931
+ .optional()
932
+ .describe(
933
+ 'Required for a live tick: the audit reason — how you checked this, not what the criterion already says. The evidence a human reads on the card is `note`.'
934
+ ),
935
+ idempotencyKey: z
936
+ .string()
937
+ .optional()
938
+ .describe('Required for a live tick. Reuse the SAME key on retry.'),
939
+ },
940
+ async (args) => {
941
+ const unauditable = args.dryRun === false ? describeUnauditableReason(args.reason) : null;
942
+ if (unauditable) return ok({ error: true, message: unauditable });
943
+
944
+ const path = criterionSatisfactionPath(args.itemId, args.criterionId);
945
+ const preview = validateApiBridgeRequest({
946
+ method: 'POST',
947
+ path,
948
+ dryRun: args.dryRun,
949
+ approved: args.approved,
950
+ reason: args.reason,
951
+ idempotencyKey: args.idempotencyKey,
952
+ grantedScope: getGrantedScope(),
953
+ });
954
+ if (preview?.dryRun) {
955
+ return ok({
956
+ ...preview,
957
+ wouldSatisfy: {
958
+ itemId: args.itemId,
959
+ criterionId: args.criterionId,
960
+ note: args.note ?? null,
961
+ },
962
+ // Said in the preview because the preview is where a caller decides
963
+ // whether to go live, and the one thing it CANNOT tell them is the
964
+ // answer they want. Predicting the grade here would need the card's
965
+ // current column and the criterion's author, and a prediction made
966
+ // from a stale read is worse than an honest refusal to predict.
967
+ gradeIsDerived:
968
+ 'The grade is computed at read time from the column this card is standing in and from who you are, so this preview cannot tell you which you will get. Ticking from a QA stage, as somebody who is neither the criterion\'s author nor the card\'s owner, grades `verified`; anything else grades `claimed`.',
969
+ });
970
+ }
971
+
972
+ const data = await api('POST', path, {
973
+ // The note is the only field this endpoint takes, and it is optional:
974
+ // `SatisfyCriterionRequest` defaults it, so an omitted note is a valid
975
+ // body rather than a missing one. Sending `{}` is deliberate — a tick
976
+ // with nothing to add must stay recordable, because the alternative is
977
+ // an agent inventing evidence to satisfy a required field.
978
+ body: args.note === undefined ? {} : { note: args.note },
979
+ headers: buildMutationHeaders(args),
980
+ });
981
+ const criterion = data?.data;
982
+ return ok({
983
+ satisfied: true,
984
+ criterion,
985
+ // The grade is the answer, and it is the server's to give. Echoing an
986
+ // optimistic "verified" from the request would be this tool asserting
987
+ // the exact fact the grading rule exists to withhold.
988
+ grade: criterion?.grade,
989
+ ...(criterion?.grade === 'claimed'
990
+ ? {
991
+ gradeNote:
992
+ 'Recorded as `claimed`, not `verified`: either the card was not standing in a QA stage, or you wrote this criterion or own the card. That is the rule working rather than a failure — the tick stands and the card face counts it. Verification needs a QA stage and a different pair of hands.',
993
+ }
994
+ : {}),
995
+ });
996
+ }
997
+ );
998
+
999
+ server.tool(
1000
+ 'unsatisfy_work_item_acceptance_criterion',
1001
+ `Un-tick one acceptance criterion, back to \`unmet\`. Use it when the check turns out not to hold, when it was ticked against the wrong build, or when it was ticked from a build column and has to be re-run at the gate to count as verified.
1002
+
1003
+ This exists so that ticking is safe to do. A box nobody can clear is a box people hesitate to tick, and that hesitation is exactly how a board ends up with a full checklist that nobody has ever touched — so shipping the tick without its counterpart would have left the same problem in a new shape.
1004
+
1005
+ It clears the WHOLE circumstance together — the ticker, the column, the note and the timestamp — never just the timestamp. Otherwise the next tick would inherit somebody else's evidence: a criterion re-ticked in Aligning would keep reading as verified in QA1, attributed to a person who was not there. So the cost is real, and it is the note: the first ticker's evidence is gone. If it is worth keeping, put it on the card with comment_on_work_item BEFORE you clear it.
1006
+
1007
+ Anyone who can see the card may un-tick, including somebody undoing another person's tick. The audit reason is the only record of why, so write it for them.${GOVERNED_NOTE}`,
1008
+ {
1009
+ itemId: z.string().describe('Card id.'),
1010
+ criterionId: z
1011
+ .string()
1012
+ .describe('Criterion id from list_work_item_acceptance_criteria — the `id`, not the `ordinal`.'),
1013
+ dryRun: z.boolean().optional().describe('Defaults to true. Set false to clear the tick.'),
1014
+ approved: z.boolean().optional().describe('Required true for a live change.'),
1015
+ reason: z
1016
+ .string()
1017
+ .optional()
1018
+ .describe(
1019
+ 'Required for a live change: why this tick is coming off — the check failed on a later build, it was run against the wrong environment, it needs re-running at the gate. This is the only record the original ticker will have of losing their evidence.'
1020
+ ),
1021
+ idempotencyKey: z
1022
+ .string()
1023
+ .optional()
1024
+ .describe('Required for a live change. Reuse the SAME key on retry.'),
1025
+ },
1026
+ async (args) => {
1027
+ const unauditable = args.dryRun === false ? describeUnauditableReason(args.reason) : null;
1028
+ if (unauditable) return ok({ error: true, message: unauditable });
1029
+
1030
+ const path = criterionSatisfactionPath(args.itemId, args.criterionId);
1031
+ const preview = validateApiBridgeRequest({
1032
+ method: 'DELETE',
1033
+ path,
1034
+ dryRun: args.dryRun,
1035
+ approved: args.approved,
1036
+ reason: args.reason,
1037
+ idempotencyKey: args.idempotencyKey,
1038
+ grantedScope: getGrantedScope(),
1039
+ });
1040
+ if (preview?.dryRun) {
1041
+ return ok({
1042
+ ...preview,
1043
+ wouldUnsatisfy: { itemId: args.itemId, criterionId: args.criterionId },
1044
+ clears:
1045
+ 'The ticker, the column the tick happened in, the evidence note, and the timestamp — all four together, so a later tick cannot inherit this one. Copy the note onto the card first if it is worth keeping.',
1046
+ });
1047
+ }
1048
+
1049
+ const data = await api('DELETE', path, { headers: buildMutationHeaders(args) });
1050
+ const criterion = data?.data;
1051
+ return ok({ unsatisfied: true, criterion, grade: criterion?.grade });
1052
+ }
1053
+ );
1054
+
888
1055
  server.tool(
889
1056
  'comment_on_work_item',
890
1057
  `Say something on a card: a question, a finding, or the reason a QA pass sent it back. This is the ONLY place to put a fact that contradicts the card — the ship-the-card skill tells you to say so on the card rather than silently fixing something else, and this is where that goes. Do NOT overwrite the card's body to make the point: the body is the original request, and rewriting it destroys the evidence of what was actually asked for.
@@ -1022,6 +1189,8 @@ export const WORK_BOARD_TOOL_NAMES = [
1022
1189
  'set_work_item_kind',
1023
1190
  'create_work_item',
1024
1191
  'add_work_item_acceptance_criterion',
1192
+ 'satisfy_work_item_acceptance_criterion',
1193
+ 'unsatisfy_work_item_acceptance_criterion',
1025
1194
  'get_work_item_comments',
1026
1195
  'comment_on_work_item',
1027
1196
  'flag_work_item',